M:erge branch 'dev' into fix-pro

This commit is contained in:
andrew 2023-07-21 15:50:59 +09:30
commit 41cb4656d8
252 changed files with 10240 additions and 3174 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "libsession-util/libsession-util"]
path = libsession-util/libsession-util
url = https://github.com/oxen-io/libsession-util.git

View File

@ -32,6 +32,7 @@ Setting up a development environment and building from Android Studio
4. Android Studio should detect the presence of a project file and ask you whether to open it. Click "yes". 4. Android Studio should detect the presence of a project file and ask you whether to open it. Click "yes".
5. Default config options should be good enough. 5. Default config options should be good enough.
6. Project initialization and building should proceed. 6. Project initialization and building should proceed.
7. Clone submodules with `git submodule update --init --recursive`
Contributing code Contributing code
----------------- -----------------

View File

@ -10,7 +10,7 @@ Add the [F-Droid repo](https://fdroid.getsession.org/)
Session integrates directly with [Oxen Service Nodes](https://docs.oxen.io/about-the-oxen-blockchain/oxen-service-nodes), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as servers which store messages offline, and a set of nodes which allow for onion routing functionality obfuscating users' IP addresses. For a full understanding of how Session works, read the [Session Whitepaper](https://getsession.org/whitepaper). Session integrates directly with [Oxen Service Nodes](https://docs.oxen.io/about-the-oxen-blockchain/oxen-service-nodes), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as servers which store messages offline, and a set of nodes which allow for onion routing functionality obfuscating users' IP addresses. For a full understanding of how Session works, read the [Session Whitepaper](https://getsession.org/whitepaper).
<img src="https://i.imgur.com/dO9f7Hg.jpg" width="320" /> <img src="https://i.imgur.com/wcdAGBh.png" width="320" />
## Want to contribute? Found a bug or have a feature request? ## Want to contribute? Found a bug or have a feature request?

View File

@ -1,3 +1,4 @@
buildscript { buildscript {
repositories { repositories {
google() google()
@ -13,6 +14,11 @@ buildscript {
} }
} }
plugins {
id 'kotlin-kapt'
id 'com.google.dagger.hilt.android'
}
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'witness' apply plugin: 'witness'
@ -22,11 +28,16 @@ apply plugin: 'com.google.gms.google-services'
apply plugin: 'kotlinx-serialization' apply plugin: 'kotlinx-serialization'
apply plugin: 'dagger.hilt.android.plugin' apply plugin: 'dagger.hilt.android.plugin'
configurations.all { configurations.all {
exclude module: "commons-logging" exclude module: "commons-logging"
} }
dependencies { dependencies {
implementation("com.google.dagger:hilt-android:2.46.1")
kapt("com.google.dagger:hilt-android-compiler:2.44")
implementation "androidx.appcompat:appcompat:$appcompatVersion" implementation "androidx.appcompat:appcompat:$appcompatVersion"
implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation "com.google.android.material:material:$materialVersion" implementation "com.google.android.material:material:$materialVersion"
@ -39,7 +50,6 @@ dependencies {
implementation 'androidx.exifinterface:exifinterface:1.3.4' implementation 'androidx.exifinterface:exifinterface:1.3.4'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion"
@ -93,17 +103,13 @@ dependencies {
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection' exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
} }
implementation 'com.annimon:stream:1.1.8' implementation 'com.annimon:stream:1.1.8'
implementation 'com.takisoft.fix:colorpicker:1.0.1'
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4' implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2' implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
implementation 'androidx.sqlite:sqlite-ktx:2.2.0' implementation 'androidx.sqlite:sqlite-ktx:2.3.1'
implementation 'net.zetetic:sqlcipher-android:4.5.3@aar' implementation 'net.zetetic:sqlcipher-android:4.5.4@aar'
implementation ('com.googlecode.ez-vcard:ez-vcard:0.9.11') {
exclude group: 'com.fasterxml.jackson.core'
exclude group: 'org.freemarker'
}
implementation project(":libsignal") implementation project(":libsignal")
implementation project(":libsession") implementation project(":libsession")
implementation project(":libsession-util")
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion"
implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version" implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version"
implementation project(":liblazysodium") implementation project(":liblazysodium")
@ -116,52 +122,62 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion" implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
implementation "com.github.lelloman:android-identicons:v11"
implementation "com.prof.rssparser:rssparser:2.0.4"
implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0" implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0"
implementation "com.github.tbruyelle:rxpermissions:0.10.2" implementation "com.github.tbruyelle:rxpermissions:0.10.2"
implementation "com.github.ybq:Android-SpinKit:1.4.0" implementation "com.github.ybq:Android-SpinKit:1.4.0"
implementation "com.opencsv:opencsv:4.6" implementation "com.opencsv:opencsv:4.6"
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
testImplementation 'org.assertj:assertj-core:3.11.1' testImplementation 'org.assertj:assertj-core:3.11.1'
testImplementation "org.mockito:mockito-inline:4.0.0" testImplementation "org.mockito:mockito-inline:4.10.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
testImplementation 'org.powermock:powermock-api-mockito:1.6.1' androidTestImplementation "org.mockito:mockito-android:4.10.0"
testImplementation 'org.powermock:powermock-module-junit4:1.6.1' androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.1'
testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1'
testImplementation "androidx.test:core:$testCoreVersion" testImplementation "androidx.test:core:$testCoreVersion"
testImplementation "androidx.arch.core:core-testing:2.1.0" testImplementation "androidx.arch.core:core-testing:2.2.0"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
// Core library // Core library
androidTestImplementation 'androidx.test:core:1.4.0' androidTestImplementation "androidx.test:core:$testCoreVersion"
androidTestImplementation('com.adevinta.android:barista:4.2.0') {
exclude group: 'org.jetbrains.kotlin'
}
// AndroidJUnitRunner and JUnit Rules // AndroidJUnitRunner and JUnit Rules
androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.4.0' androidTestImplementation 'androidx.test:rules:1.5.0'
// Assertions // Assertions
androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.ext:truth:1.4.0' androidTestImplementation 'androidx.test.ext:truth:1.5.0'
androidTestImplementation 'com.google.truth:truth:1.1.3' androidTestImplementation 'com.google.truth:truth:1.1.3'
// Espresso dependencies // Espresso dependencies
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-web:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.4.0' androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.4.0' androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
androidTestUtil 'androidx.test:orchestrator:1.4.1' androidTestUtil 'androidx.test:orchestrator:1.4.2'
testImplementation 'org.robolectric:robolectric:4.4' testImplementation 'org.robolectric:robolectric:4.4'
testImplementation 'org.robolectric:shadows-multidex:4.4' testImplementation 'org.robolectric:shadows-multidex:4.4'
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.1'
implementation 'androidx.compose.ui:ui:1.4.3'
implementation 'androidx.compose.ui:ui-tooling:1.4.3'
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.31.5-beta"
implementation "com.google.accompanist:accompanist-pager-indicators:0.31.5-beta"
implementation "androidx.compose.runtime:runtime-livedata:1.4.3"
implementation 'androidx.compose.foundation:foundation-layout:1.5.0-alpha02'
implementation 'androidx.compose.material:material:1.5.0-alpha02'
} }
def canonicalVersionCode = 338 def canonicalVersionCode = 354
def canonicalVersionName = "1.16.9" def canonicalVersionName = "1.17.0"
def postFixSize = 10 def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1, def abiPostFix = ['armeabi-v7a' : 1,
@ -203,6 +219,13 @@ android {
} }
} }
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.4.7'
}
defaultConfig { defaultConfig {
versionCode canonicalVersionCode * postFixSize versionCode canonicalVersionCode * postFixSize
versionName canonicalVersionName versionName canonicalVersionName
@ -309,3 +332,8 @@ def autoResConfig() {
.collect { matcher -> matcher.group(1) } .collect { matcher -> matcher.group(1) }
.sort() .sort()
} }
// Allow references to generated code
kapt {
correctErrorTypes = true
}

View File

@ -1,5 +1,6 @@
package network.loki.messenger package network.loki.messenger
import android.Manifest
import android.app.Instrumentation import android.app.Instrumentation
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
@ -21,6 +22,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.adevinta.android.barista.interaction.PermissionGranter
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
import org.hamcrest.Matcher import org.hamcrest.Matcher
import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.allOf
@ -85,6 +87,8 @@ class HomeActivityTests {
} }
onView(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click()) onView(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click())
onView(withId(R.id.registerButton)).perform(ViewActions.click()) onView(withId(R.id.registerButton)).perform(ViewActions.click())
// allow notification permission
PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS)
} }
private fun goToMyChat() { private fun goToMyChat() {
@ -100,6 +104,7 @@ class HomeActivityTests {
copied = clipboardManager.primaryClip!!.getItemAt(0).text.toString() copied = clipboardManager.primaryClip!!.getItemAt(0).text.toString()
} }
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.typeText(copied)) onView(withId(R.id.publicKeyEditText)).perform(ViewActions.typeText(copied))
onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard())
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click()) onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
} }

View File

@ -0,0 +1,102 @@
package network.loki.messenger
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.util.Contact
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.argThat
import org.mockito.kotlin.eq
import org.mockito.kotlin.spy
import org.mockito.kotlin.verify
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
@SmallTest
class LibSessionTests {
private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray())
private fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
private var fakeHashI = 0
private val nextFakeHash: String
get() = "fakehash${fakeHashI++}"
private fun maybeGetUserInfo(): Pair<ByteArray, String>? {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val prefs = appContext.prefs
val localUserPublicKey = prefs.getLocalNumber()
val secretKey = with(appContext) {
val edKey = KeyPairUtilities.getUserED25519KeyPair(this) ?: return null
edKey.secretKey.asBytes
}
return if (localUserPublicKey == null || secretKey == null) null
else secretKey to localUserPublicKey
}
private fun buildContactMessage(contactList: List<Contact>): ByteArray {
val (key,_) = maybeGetUserInfo()!!
val contacts = Contacts.Companion.newInstance(key)
contactList.forEach { contact ->
contacts.set(contact)
}
return contacts.push().config
}
private fun fakePollNewConfig(configBase: ConfigBase, toMerge: ByteArray) {
configBase.merge(nextFakeHash to toMerge)
MessagingModuleConfiguration.shared.configFactory.persist(configBase, System.currentTimeMillis())
}
@Before
fun setupUser() {
PreferenceManager.getDefaultSharedPreferences(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext).edit {
putBoolean(TextSecurePreferences.HAS_FORCED_NEW_CONFIG, true).apply()
}
val newBytes = randomSeedBytes().toByteArray()
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
val kp = KeyPairUtilities.generate(newBytes)
KeyPairUtilities.store(context, kp.seed, kp.ed25519KeyPair, kp.x25519KeyPair)
val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(context, registrationID)
TextSecurePreferences.setLocalNumber(context, kp.x25519KeyPair.hexEncodedPublicKey)
TextSecurePreferences.setRestorationTime(context, 0)
TextSecurePreferences.setHasViewedSeed(context, false)
}
@Test
fun migration_one_to_ones() {
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext
val storageSpy = spy(app.storage)
app.storage = storageSpy
val newContactId = randomSessionId()
val singleContact = Contact(
id = newContactId,
approved = true,
expiryMode = ExpiryMode.NONE
)
val newContactMerge = buildContactMessage(listOf(singleContact))
val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!!
fakePollNewConfig(contacts, newContactMerge)
verify(storageSpy).addLibSessionContacts(argThat {
first().let { it.id == newContactId && it.approved } && size == 1
})
verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true))
}
}

View File

@ -29,12 +29,16 @@
android:name="android.hardware.touchscreen" android:name="android.hardware.touchscreen"
android:required="false" /> android:required="false" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" /> <uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="network.loki.messenger.ACCESS_SESSION_SECRETS" /> <uses-permission android:name="network.loki.messenger.ACCESS_SESSION_SECRETS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

View File

@ -40,6 +40,7 @@ import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPol
import org.session.libsession.messaging.sending_receiving.pollers.Poller; import org.session.libsession.messaging.sending_receiving.pollers.Poller;
import org.session.libsession.snode.SnodeModule; import org.session.libsession.snode.SnodeModule;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.ConfigFactoryUpdateListener;
import org.session.libsession.utilities.ProfilePictureUtilities; import org.session.libsession.utilities.ProfilePictureUtilities;
import org.session.libsession.utilities.SSKEnvironment; import org.session.libsession.utilities.SSKEnvironment;
import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.TextSecurePreferences;
@ -59,6 +60,8 @@ import org.thoughtcrime.securesms.database.LokiAPIDatabase;
import org.thoughtcrime.securesms.database.Storage; import org.thoughtcrime.securesms.database.Storage;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.EmojiSearchData; import org.thoughtcrime.securesms.database.model.EmojiSearchData;
import org.thoughtcrime.securesms.dependencies.AppComponent;
import org.thoughtcrime.securesms.dependencies.ConfigFactory;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.dependencies.DatabaseModule; import org.thoughtcrime.securesms.dependencies.DatabaseModule;
import org.thoughtcrime.securesms.emoji.EmojiSource; import org.thoughtcrime.securesms.emoji.EmojiSource;
@ -106,6 +109,8 @@ import dagger.hilt.EntryPoints;
import dagger.hilt.android.HiltAndroidApp; import dagger.hilt.android.HiltAndroidApp;
import kotlin.Unit; import kotlin.Unit;
import kotlinx.coroutines.Job; import kotlinx.coroutines.Job;
import network.loki.messenger.libsession_util.ConfigBase;
import network.loki.messenger.libsession_util.UserProfile;
/** /**
* Will be called once when the TextSecure process is created. * Will be called once when the TextSecure process is created.
@ -116,7 +121,7 @@ import kotlinx.coroutines.Job;
* @author Moxie Marlinspike * @author Moxie Marlinspike
*/ */
@HiltAndroidApp @HiltAndroidApp
public class ApplicationContext extends Application implements DefaultLifecycleObserver { public class ApplicationContext extends Application implements DefaultLifecycleObserver, ConfigFactoryUpdateListener {
public static final String PREFERENCES_NAME = "SecureSMS-Preferences"; public static final String PREFERENCES_NAME = "SecureSMS-Preferences";
@ -137,9 +142,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
private PersistentLogger persistentLogger; private PersistentLogger persistentLogger;
@Inject LokiAPIDatabase lokiAPIDatabase; @Inject LokiAPIDatabase lokiAPIDatabase;
@Inject Storage storage; @Inject public Storage storage;
@Inject MessageDataProvider messageDataProvider; @Inject MessageDataProvider messageDataProvider;
@Inject TextSecurePreferences textSecurePreferences; @Inject TextSecurePreferences textSecurePreferences;
@Inject ConfigFactory configFactory;
CallMessageProcessor callMessageProcessor; CallMessageProcessor callMessageProcessor;
MessagingModuleConfiguration messagingModuleConfiguration; MessagingModuleConfiguration messagingModuleConfiguration;
@ -157,6 +163,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
return (ApplicationContext) context.getApplicationContext(); return (ApplicationContext) context.getApplicationContext();
} }
public TextSecurePreferences getPrefs() {
return EntryPoints.get(getApplicationContext(), AppComponent.class).getPrefs();
}
public DatabaseComponent getDatabaseComponent() { public DatabaseComponent getDatabaseComponent() {
return EntryPoints.get(getApplicationContext(), DatabaseComponent.class); return EntryPoints.get(getApplicationContext(), DatabaseComponent.class);
} }
@ -183,6 +193,15 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
return this.persistentLogger; return this.persistentLogger;
} }
@Override
public void notifyUpdates(@NonNull ConfigBase forConfigObject) {
// forward to the config factory / storage ig
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
textSecurePreferences.setConfigurationMessageSynced(true);
}
storage.notifyConfigUpdates(forConfigObject);
}
@Override @Override
public void onCreate() { public void onCreate() {
DatabaseModule.init(this); DatabaseModule.init(this);
@ -191,7 +210,9 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
messagingModuleConfiguration = new MessagingModuleConfiguration(this, messagingModuleConfiguration = new MessagingModuleConfiguration(this,
storage, storage,
messageDataProvider, messageDataProvider,
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this)); ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
configFactory
);
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()"); Log.i(TAG, "onCreate()");
startKovenant(); startKovenant();
@ -347,7 +368,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
} }
private void initializeProfileManager() { private void initializeProfileManager() {
this.profileManager = new ProfileManager(); this.profileManager = new ProfileManager(this, configFactory);
} }
private void initializeTypingStatusSender() { private void initializeTypingStatusSender() {
@ -440,7 +461,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
poller.setUserPublicKey(userPublicKey); poller.setUserPublicKey(userPublicKey);
return; return;
} }
poller = new Poller(); poller = new Poller(configFactory, new Timer());
} }
public void startPollingIfNeeded() { public void startPollingIfNeeded() {
@ -483,6 +504,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}); });
} catch (Exception exception) { } catch (Exception exception) {
// Do nothing // Do nothing
Log.e("Loki-Avatar", "Uploading avatar failed", exception);
} }
}); });
} }
@ -520,6 +542,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) { if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) {
Log.d("Loki", "Failed to delete database."); Log.d("Loki", "Failed to delete database.");
} }
configFactory.keyPairChanged();
Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200)); Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200));
} }

View File

@ -0,0 +1,28 @@
package org.thoughtcrime.securesms
import android.content.Context
import network.loki.messenger.R
class DeleteMediaDialog {
companion object {
@JvmStatic
fun show(context: Context, recordCount: Int, doDelete: Runnable) = context.showSessionDialog {
iconAttribute(R.attr.dialog_alert_icon)
title(
context.resources.getQuantityString(
R.plurals.MediaOverviewActivity_Media_delete_confirm_title,
recordCount,
recordCount
)
)
text(
context.resources.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message,
recordCount,
recordCount
)
)
button(R.string.delete) { doDelete.run() }
cancelButton()
}
}
}

View File

@ -0,0 +1,19 @@
package org.thoughtcrime.securesms
import android.content.Context
import network.loki.messenger.R
class DeleteMediaPreviewDialog {
companion object {
@JvmStatic
fun show(context: Context, doDelete: Runnable) {
context.showSessionDialog {
iconAttribute(R.attr.dialog_alert_icon)
title(R.string.MediaPreviewActivity_media_delete_confirmation_title)
text(R.string.MediaPreviewActivity_media_delete_confirmation_message)
button(R.string.delete) { doDelete.run() }
cancelButton()
}
}
}
}

View File

@ -1,88 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;
import org.session.libsession.utilities.ExpirationUtil;
import cn.carbswang.android.numberpickerview.library.NumberPickerView;
import network.loki.messenger.R;
public class ExpirationDialog extends AlertDialog {
protected ExpirationDialog(Context context) {
super(context);
}
protected ExpirationDialog(Context context, int theme) {
super(context, theme);
}
protected ExpirationDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
super(context, cancelable, cancelListener);
}
public static void show(final Context context,
final int currentExpiration,
final @NonNull OnClickListener listener)
{
final View view = createNumberPickerView(context, currentExpiration);
AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setTitle(context.getString(R.string.ExpirationDialog_disappearing_messages));
builder.setView(view);
builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
int selected = ((NumberPickerView)view.findViewById(R.id.expiration_number_picker)).getValue();
listener.onClick(context.getResources().getIntArray(R.array.expiration_times)[selected]);
});
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
}
private static View createNumberPickerView(final Context context, final int currentExpiration) {
final LayoutInflater inflater = LayoutInflater.from(context);
final View view = inflater.inflate(R.layout.expiration_dialog, null);
final NumberPickerView numberPickerView = view.findViewById(R.id.expiration_number_picker);
final TextView textView = view.findViewById(R.id.expiration_details);
final int[] expirationTimes = context.getResources().getIntArray(R.array.expiration_times);
final String[] expirationDisplayValues = new String[expirationTimes.length];
int selectedIndex = expirationTimes.length - 1;
for (int i=0;i<expirationTimes.length;i++) {
expirationDisplayValues[i] = ExpirationUtil.getExpirationDisplayValue(context, expirationTimes[i]);
if ((currentExpiration >= expirationTimes[i]) &&
(i == expirationTimes.length -1 || currentExpiration < expirationTimes[i+1])) {
selectedIndex = i;
}
}
numberPickerView.setDisplayedValues(expirationDisplayValues);
numberPickerView.setMinValue(0);
numberPickerView.setMaxValue(expirationTimes.length-1);
NumberPickerView.OnValueChangeListener listener = (picker, oldVal, newVal) -> {
if (newVal == 0) {
textView.setText(R.string.ExpirationDialog_your_messages_will_not_expire);
} else {
textView.setText(context.getString(R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen, picker.getDisplayedValues()[newVal]));
}
};
numberPickerView.setOnValueChangedListener(listener);
numberPickerView.setValue(selectedIndex);
listener.onValueChange(numberPickerView, selectedIndex, selectedIndex);
return view;
}
public interface OnClickListener {
public void onClick(int expirationTime);
}
}

View File

@ -0,0 +1,51 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.LayoutInflater
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import cn.carbswang.android.numberpickerview.library.NumberPickerView
import network.loki.messenger.R
import org.session.libsession.utilities.ExpirationUtil
fun Context.showExpirationDialog(
expiration: Int,
onExpirationTime: (Int) -> Unit
): AlertDialog {
val view = LayoutInflater.from(this).inflate(R.layout.expiration_dialog, null)
val numberPickerView = view.findViewById<NumberPickerView>(R.id.expiration_number_picker)
fun updateText(index: Int) {
view.findViewById<TextView>(R.id.expiration_details).text = when (index) {
0 -> getString(R.string.ExpirationDialog_your_messages_will_not_expire)
else -> getString(
R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen,
numberPickerView.displayedValues[index]
)
}
}
val expirationTimes = resources.getIntArray(R.array.expiration_times)
val expirationDisplayValues = expirationTimes
.map { ExpirationUtil.getExpirationDisplayValue(this, it) }
.toTypedArray()
val selectedIndex = expirationTimes.run { indexOfFirst { it >= expiration }.coerceIn(indices) }
numberPickerView.apply {
displayedValues = expirationDisplayValues
minValue = 0
maxValue = expirationTimes.lastIndex
setOnValueChangedListener { _, _, index -> updateText(index) }
value = selectedIndex
}
updateText(selectedIndex)
return showSessionDialog {
title(getString(R.string.ExpirationDialog_disappearing_messages))
view(view)
okButton { onExpirationTime(numberPickerView.let { expirationTimes[it.value] }) }
cancelButton()
}
}

View File

@ -76,6 +76,7 @@ import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import kotlin.Unit;
import network.loki.messenger.R; import network.loki.messenger.R;
/** /**
@ -318,9 +319,9 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
@SuppressWarnings("CodeBlock2Expr") @SuppressWarnings("CodeBlock2Expr")
@SuppressLint({"InlinedApi", "StaticFieldLeak"}) @SuppressLint({"InlinedApi", "StaticFieldLeak"})
private void handleSaveMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) { private void handleSaveMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
final Context context = getContext(); final Context context = requireContext();
SaveAttachmentTask.showWarningDialog(context, (dialogInterface, which) -> { SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> {
Permissions.with(this) Permissions.with(this)
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
.maxSdkVersion(Build.VERSION_CODES.P) .maxSdkVersion(Build.VERSION_CODES.P)
@ -362,7 +363,8 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
}.execute(); }.execute();
}) })
.execute(); .execute();
}, mediaRecords.size()); return Unit.INSTANCE;
});
} }
private void sendMediaSavedNotificationIfNeeded() { private void sendMediaSavedNotificationIfNeeded() {
@ -374,41 +376,26 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity {
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
private void handleDeleteMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) { private void handleDeleteMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) {
int recordCount = mediaRecords.size(); int recordCount = mediaRecords.size();
Resources res = getContext().getResources();
String confirmTitle = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_title,
recordCount,
recordCount);
String confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message,
recordCount,
recordCount);
AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); DeleteMediaDialog.show(
builder.setIconAttribute(R.attr.dialog_alert_icon); requireContext(),
builder.setTitle(confirmTitle); recordCount,
builder.setMessage(confirmMessage); () -> new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(
builder.setCancelable(true); requireContext(),
R.string.MediaOverviewActivity_Media_delete_progress_title,
builder.setPositiveButton(R.string.delete, (dialogInterface, i) -> { R.string.MediaOverviewActivity_Media_delete_progress_message) {
new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(getContext(), @Override
R.string.MediaOverviewActivity_Media_delete_progress_title, protected Void doInBackground(MediaDatabase.MediaRecord... records) {
R.string.MediaOverviewActivity_Media_delete_progress_message) if (records == null || records.length == 0) {
{
@Override
protected Void doInBackground(MediaDatabase.MediaRecord... records) {
if (records == null || records.length == 0) {
return null;
}
for (MediaDatabase.MediaRecord record : records) {
AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
}
return null; return null;
} }
}.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])); for (MediaDatabase.MediaRecord record : records) {
}); AttachmentUtil.deleteAttachment(getContext(), record.getAttachment());
builder.setNegativeButton(android.R.string.cancel, null); }
builder.show(); return null;
}
}.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])));
} }
private void handleSelectAllMedia() { private void handleSelectAllMedia() {

View File

@ -85,6 +85,7 @@ import java.io.IOException;
import java.util.Locale; import java.util.Locale;
import java.util.WeakHashMap; import java.util.WeakHashMap;
import kotlin.Unit;
import network.loki.messenger.R; import network.loki.messenger.R;
/** /**
@ -146,6 +147,10 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
} }
}; };
public static Intent getPreviewIntent(Context context, MediaPreviewArgs args) {
return getPreviewIntent(context, args.getSlide(), args.getMmsRecord(), args.getThread());
}
public static Intent getPreviewIntent(Context context, Slide slide, MmsMessageRecord mms, Recipient threadRecipient) { public static Intent getPreviewIntent(Context context, Slide slide, MmsMessageRecord mms, Recipient threadRecipient) {
Intent previewIntent = null; Intent previewIntent = null;
if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) { if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) {
@ -416,7 +421,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
MediaItem mediaItem = getCurrentMediaItem(); MediaItem mediaItem = getCurrentMediaItem();
if (mediaItem == null) return; if (mediaItem == null) return;
SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> { SaveAttachmentTask.showWarningDialog(this, 1, () -> {
Permissions.with(this) Permissions.with(this)
.request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
.maxSdkVersion(Build.VERSION_CODES.P) .maxSdkVersion(Build.VERSION_CODES.P)
@ -433,6 +438,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
} }
}) })
.execute(); .execute();
return Unit.INSTANCE;
}); });
} }
@ -449,29 +455,20 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
return; return;
} }
AlertDialog.Builder builder = new AlertDialog.Builder(this); DeleteMediaPreviewDialog.show(this, () -> {
builder.setIconAttribute(R.attr.dialog_alert_icon); new AsyncTask<Void, Void, Void>() {
builder.setTitle(R.string.MediaPreviewActivity_media_delete_confirmation_title); @Override
builder.setMessage(R.string.MediaPreviewActivity_media_delete_confirmation_message); protected Void doInBackground(Void... voids) {
builder.setCancelable(true); DatabaseAttachment attachment = mediaItem.attachment;
if (attachment != null) {
builder.setPositiveButton(R.string.delete, (dialogInterface, which) -> { AttachmentUtil.deleteAttachment(getApplicationContext(), attachment);
new AsyncTask<Void, Void, Void>() { }
@Override return null;
protected Void doInBackground(Void... voids) { }
if (mediaItem.attachment == null) { }.execute();
return null;
}
AttachmentUtil.deleteAttachment(MediaPreviewActivity.this.getApplicationContext(),
mediaItem.attachment);
return null;
}
}.execute();
finish(); finish();
}); });
builder.setNegativeButton(android.R.string.cancel, null);
builder.show();
} }
@Override @Override
@ -531,7 +528,6 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
@Override @Override
public void onLoadFinished(@NonNull Loader<Pair<Cursor, Integer>> loader, @Nullable Pair<Cursor, Integer> data) { public void onLoadFinished(@NonNull Loader<Pair<Cursor, Integer>> loader, @Nullable Pair<Cursor, Integer> data) {
if (data != null) { if (data != null) {
@SuppressWarnings("ConstantConditions")
CursorPagerAdapter adapter = new CursorPagerAdapter(this, GlideApp.with(this), getWindow(), data.first, data.second, leftIsRecent); CursorPagerAdapter adapter = new CursorPagerAdapter(this, GlideApp.with(this), getWindow(), data.first, data.second, leftIsRecent);
mediaPager.setAdapter(adapter); mediaPager.setAdapter(adapter);
adapter.setActive(true); adapter.setActive(true);

View File

@ -0,0 +1,11 @@
package org.thoughtcrime.securesms
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.Slide
data class MediaPreviewArgs(
val slide: Slide,
val mmsRecord: MmsMessageRecord?,
val thread: Recipient?,
)

View File

@ -1,56 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import android.content.DialogInterface;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import java.util.concurrent.TimeUnit;
import network.loki.messenger.R;
public class MuteDialog extends AlertDialog {
protected MuteDialog(Context context) {
super(context);
}
protected MuteDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
super(context, cancelable, cancelListener);
}
protected MuteDialog(Context context, int theme) {
super(context, theme);
}
public static void show(final Context context, final @NonNull MuteSelectionListener listener) {
AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setTitle(R.string.MuteDialog_mute_notifications);
builder.setItems(R.array.mute_durations, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, final int which) {
final long muteUntil;
switch (which) {
case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(2); break;
case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break;
case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break;
case 4: muteUntil = Long.MAX_VALUE; break;
default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
}
listener.onMuted(muteUntil);
}
});
builder.show();
}
public interface MuteSelectionListener {
public void onMuted(long until);
}
}

View File

@ -0,0 +1,27 @@
package org.thoughtcrime.securesms
import android.content.Context
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import network.loki.messenger.R
import java.util.concurrent.TimeUnit
fun showMuteDialog(
context: Context,
onMuteDuration: (Long) -> Unit
): AlertDialog = context.showSessionDialog {
title(R.string.MuteDialog_mute_notifications)
items(Option.values().map { it.stringRes }.map(context::getString).toTypedArray()) {
onMuteDuration(Option.values()[it].getTime())
}
}
private enum class Option(@StringRes val stringRes: Int, val getTime: () -> Long) {
ONE_HOUR(R.string.arrays__mute_for_one_hour, duration = TimeUnit.HOURS.toMillis(1)),
TWO_HOURS(R.string.arrays__mute_for_two_hours, duration = TimeUnit.DAYS.toMillis(2)),
ONE_DAY(R.string.arrays__mute_for_one_day, duration = TimeUnit.DAYS.toMillis(1)),
SEVEN_DAYS(R.string.arrays__mute_for_seven_days, duration = TimeUnit.DAYS.toMillis(7)),
FOREVER(R.string.arrays__mute_forever, getTime = { Long.MAX_VALUE });
constructor(@StringRes stringRes: Int, duration: Long): this(stringRes, { System.currentTimeMillis() + duration })
}

View File

@ -0,0 +1,146 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.Button
import android.widget.LinearLayout
import android.widget.LinearLayout.VERTICAL
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.annotation.LayoutRes
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import androidx.appcompat.app.AlertDialog
import androidx.core.view.setMargins
import androidx.core.view.setPadding
import androidx.core.view.updateMargins
import androidx.fragment.app.Fragment
import network.loki.messenger.R
import org.thoughtcrime.securesms.util.toPx
@DslMarker
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
annotation class DialogDsl
@DialogDsl
class SessionDialogBuilder(val context: Context) {
private val dp20 = toPx(20, context.resources)
private val dp40 = toPx(40, context.resources)
private val dialogBuilder: AlertDialog.Builder = AlertDialog.Builder(context)
private var dialog: AlertDialog? = null
private fun dismiss() = dialog?.dismiss()
private val topView = LinearLayout(context).apply { orientation = VERTICAL }
.also(dialogBuilder::setCustomTitle)
private val contentView = LinearLayout(context).apply { orientation = VERTICAL }
private val buttonLayout = LinearLayout(context)
private val root = LinearLayout(context).apply { orientation = VERTICAL }
.also(dialogBuilder::setView)
.apply {
addView(contentView)
addView(buttonLayout)
}
fun title(@StringRes id: Int) = title(context.getString(id))
fun title(text: CharSequence?) = title(text?.toString())
fun title(text: String?) {
text(text, R.style.TextAppearance_AppCompat_Title) { setPadding(dp20) }
}
fun text(@StringRes id: Int, style: Int = 0) = text(context.getString(id), style)
fun text(text: CharSequence?, @StyleRes style: Int = 0) {
text(text, style) {
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
.apply { updateMargins(dp40, 0, dp40, dp20) }
}
}
private fun text(text: CharSequence?, @StyleRes style: Int, modify: TextView.() -> Unit) {
text ?: return
TextView(context, null, 0, style)
.apply {
setText(text)
textAlignment = View.TEXT_ALIGNMENT_CENTER
modify()
}.let(topView::addView)
}
fun view(view: View) = contentView.addView(view)
fun view(@LayoutRes layout: Int): View = LayoutInflater.from(context).inflate(layout, contentView)
fun iconAttribute(@AttrRes icon: Int): AlertDialog.Builder = dialogBuilder.setIconAttribute(icon)
fun singleChoiceItems(
options: Collection<String>,
currentSelected: Int = 0,
onSelect: (Int) -> Unit
) = singleChoiceItems(options.toTypedArray(), currentSelected, onSelect)
fun singleChoiceItems(
options: Array<String>,
currentSelected: Int = 0,
onSelect: (Int) -> Unit
): AlertDialog.Builder = dialogBuilder.setSingleChoiceItems(
options,
currentSelected
) { dialog, it -> onSelect(it); dialog.dismiss() }
fun items(
options: Array<String>,
onSelect: (Int) -> Unit
): AlertDialog.Builder = dialogBuilder.setItems(
options,
) { dialog, it -> onSelect(it); dialog.dismiss() }
fun destructiveButton(
@StringRes text: Int,
@StringRes contentDescription: Int,
listener: () -> Unit = {}
) = button(
text,
contentDescription,
R.style.Widget_Session_Button_Dialog_DestructiveText,
listener
)
fun okButton(listener: (() -> Unit) = {}) = button(android.R.string.ok, listener = listener)
fun cancelButton(listener: (() -> Unit) = {}) = button(android.R.string.cancel, R.string.AccessibilityId_cancel_button, listener = listener)
fun button(
@StringRes text: Int,
@StringRes contentDescriptionRes: Int = text,
@StyleRes style: Int = R.style.Widget_Session_Button_Dialog_UnimportantText,
listener: (() -> Unit) = {}
) = Button(context, null, 0, style).apply {
setText(text)
contentDescription = resources.getString(contentDescriptionRes)
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, 1f)
.apply { setMargins(toPx(20, resources)) }
setOnClickListener {
listener.invoke()
dismiss()
}
}.let(buttonLayout::addView)
fun create(): AlertDialog = dialogBuilder.create().also { dialog = it }
fun show(): AlertDialog = dialogBuilder.show().also { dialog = it }
}
fun Context.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(this).apply { build() }.show()
fun Fragment.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(requireContext()).apply { build() }.show()
fun Fragment.createSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(requireContext()).apply { build() }.create()

View File

@ -249,17 +249,12 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
viewModel.callState.collect { state -> viewModel.callState.collect { state ->
Log.d("Loki", "Consuming view model state $state") Log.d("Loki", "Consuming view model state $state")
when (state) { when (state) {
CALL_RINGING -> { CALL_RINGING -> if (wantsToAnswer) {
if (wantsToAnswer) { answerCall()
answerCall()
wantsToAnswer = false
}
}
CALL_OUTGOING -> {
}
CALL_CONNECTED -> {
wantsToAnswer = false wantsToAnswer = false
} }
CALL_CONNECTED -> wantsToAnswer = false
else -> {}
} }
updateControls(state) updateControls(state)
} }

View File

@ -2,12 +2,14 @@ package org.thoughtcrime.securesms.components
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import android.widget.RelativeLayout import android.widget.RelativeLayout
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewProfilePictureBinding import network.loki.messenger.databinding.ViewProfilePictureBinding
import network.loki.messenger.databinding.ViewUserBinding
import org.session.libsession.avatars.ContactColors import org.session.libsession.avatars.ContactColors
import org.session.libsession.avatars.PlaceholderAvatarPhoto import org.session.libsession.avatars.PlaceholderAvatarPhoto
import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.avatars.ProfileContactPhoto
@ -17,13 +19,14 @@ import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
class ProfilePictureView @JvmOverloads constructor( class ProfilePictureView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null context: Context, attrs: AttributeSet? = null
) : RelativeLayout(context, attrs) { ) : RelativeLayout(context, attrs) {
private val binding: ViewProfilePictureBinding by lazy { ViewProfilePictureBinding.bind(this) } private val binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this)
lateinit var glide: GlideRequests private val glide: GlideRequests = GlideApp.with(this)
var publicKey: String? = null var publicKey: String? = null
var displayName: String? = null var displayName: String? = null
var additionalPublicKey: String? = null var additionalPublicKey: String? = null
@ -31,13 +34,17 @@ class ProfilePictureView @JvmOverloads constructor(
var isLarge = false var isLarge = false
private val profilePicturesCache = mutableMapOf<View, Recipient>() private val profilePicturesCache = mutableMapOf<View, Recipient>()
private val unknownRecipientDrawable = ResourceContactPhoto(R.drawable.ic_profile_default) private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
private val unknownOpenGroupDrawable = ResourceContactPhoto(R.drawable.ic_notification) private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
// endregion // endregion
constructor(context: Context, sender: Recipient): this(context) {
update(sender)
}
// region Updating // region Updating
fun update(recipient: Recipient) { fun update(recipient: Recipient) {
fun getUserDisplayName(publicKey: String): String { fun getUserDisplayName(publicKey: String): String {
@ -51,12 +58,19 @@ class ProfilePictureView @JvmOverloads constructor(
.sorted() .sorted()
.take(2) .take(2)
.toMutableList() .toMutableList()
val pk = members.getOrNull(0)?.serialize() ?: "" if (members.size <= 1) {
publicKey = pk publicKey = ""
displayName = getUserDisplayName(pk) displayName = ""
val apk = members.getOrNull(1)?.serialize() ?: "" additionalPublicKey = ""
additionalPublicKey = apk additionalDisplayName = ""
additionalDisplayName = getUserDisplayName(apk) } else {
val pk = members.getOrNull(0)?.serialize() ?: ""
publicKey = pk
displayName = getUserDisplayName(pk)
val apk = members.getOrNull(1)?.serialize() ?: ""
additionalPublicKey = apk
additionalDisplayName = getUserDisplayName(apk)
}
} else if(recipient.isOpenGroupInboxRecipient) { } else if(recipient.isOpenGroupInboxRecipient) {
val publicKey = GroupUtil.getDecodedOpenGroupInbox(recipient.address.serialize()) val publicKey = GroupUtil.getDecodedOpenGroupInbox(recipient.address.serialize())
this.publicKey = publicKey this.publicKey = publicKey
@ -72,7 +86,6 @@ class ProfilePictureView @JvmOverloads constructor(
} }
fun update() { fun update() {
if (!this::glide.isInitialized) return
val publicKey = publicKey ?: return val publicKey = publicKey ?: return
val additionalPublicKey = additionalPublicKey val additionalPublicKey = additionalPublicKey
if (additionalPublicKey != null) { if (additionalPublicKey != null) {
@ -104,31 +117,38 @@ class ProfilePictureView @JvmOverloads constructor(
if (publicKey.isNotEmpty()) { if (publicKey.isNotEmpty()) {
val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false) val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false)
if (profilePicturesCache[imageView] == recipient) return if (profilePicturesCache[imageView] == recipient) return
profilePicturesCache[imageView] = recipient
val signalProfilePicture = recipient.contactPhoto val signalProfilePicture = recipient.contactPhoto
val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject
glide.clear(imageView) glide.clear(imageView)
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
if (signalProfilePicture != null && avatar != "0" && avatar != "") { if (signalProfilePicture != null && avatar != "0" && avatar != "") {
glide.load(signalProfilePicture) glide.load(signalProfilePicture)
.placeholder(unknownRecipientDrawable) .placeholder(unknownRecipientDrawable)
.centerCrop() .centerCrop()
.error(unknownRecipientDrawable) .error(glide.load(placeholder))
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.circleCrop() .circleCrop()
.into(imageView) .into(imageView)
} else if (recipient.isOpenGroupRecipient && recipient.groupAvatarId == null) { } else if (recipient.isOpenGroupRecipient && recipient.groupAvatarId == null) {
imageView.setImageDrawable(unknownOpenGroupDrawable) glide.load(unknownOpenGroupDrawable)
.centerCrop()
.circleCrop()
.into(imageView)
} else { } else {
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
glide.load(placeholder) glide.load(placeholder)
.placeholder(unknownRecipientDrawable) .placeholder(unknownRecipientDrawable)
.centerCrop() .centerCrop()
.circleCrop()
.diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView) .diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView)
} }
profilePicturesCache[imageView] = recipient
} else { } else {
imageView.setImageDrawable(null) glide.load(unknownRecipientDrawable)
.centerCrop()
.into(imageView)
} }
} }

View File

@ -7,6 +7,7 @@ import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewUserBinding import network.loki.messenger.databinding.ViewUserBinding
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
@ -47,15 +48,14 @@ class UserView : LinearLayout {
// region Updating // region Updating
fun bind(user: Recipient, glide: GlideRequests, actionIndicator: ActionIndicator, isSelected: Boolean = false) { fun bind(user: Recipient, glide: GlideRequests, actionIndicator: ActionIndicator, isSelected: Boolean = false) {
val isLocalUser = user.isLocalNumber
fun getUserDisplayName(publicKey: String): String { fun getUserDisplayName(publicKey: String): String {
if (isLocalUser) return context.getString(R.string.MessageRecord_you)
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey)
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
} }
val threadID = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(user)
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadID, context) // FIXME: This is a bad place to do this
val address = user.address.serialize() val address = user.address.serialize()
binding.profilePictureView.root.glide = glide binding.profilePictureView.update(user)
binding.profilePictureView.root.update(user)
binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24) binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24)
binding.nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address) binding.nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address)
when (actionIndicator) { when (actionIndicator) {
@ -87,7 +87,7 @@ class UserView : LinearLayout {
} }
fun unbind() { fun unbind() {
binding.profilePictureView.root.recycle() binding.profilePictureView.recycle()
} }
// endregion // endregion
} }

View File

@ -32,14 +32,13 @@ class ContactListAdapter(
class ContactViewHolder(private val binding: ViewContactBinding) : RecyclerView.ViewHolder(binding.root) { class ContactViewHolder(private val binding: ViewContactBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(contact: ContactListItem.Contact, glide: GlideRequests, listener: (Recipient) -> Unit) { fun bind(contact: ContactListItem.Contact, glide: GlideRequests, listener: (Recipient) -> Unit) {
binding.profilePictureView.root.glide = glide binding.profilePictureView.update(contact.recipient)
binding.profilePictureView.root.update(contact.recipient)
binding.nameTextView.text = contact.displayName binding.nameTextView.text = contact.displayName
binding.root.setOnClickListener { listener(contact.recipient) } binding.root.setOnClickListener { listener(contact.recipient) }
} }
fun unbind() { fun unbind() {
binding.profilePictureView.root.recycle() binding.profilePictureView.recycle()
} }
} }

View File

@ -55,7 +55,7 @@ class NewConversationHomeFragment : Fragment() {
val displayName = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionId val displayName = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionId
ContactListItem.Contact(it, displayName) ContactListItem.Contact(it, displayName)
}.sortedBy { it.displayName } }.sortedBy { it.displayName }
.groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.first().uppercase() } .groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.firstOrNull()?.uppercase() ?: unknownSectionTitle }
.toMutableMap() .toMutableMap()
contactGroups.remove(unknownSectionTitle)?.let { contactGroups.put(unknownSectionTitle, it) } contactGroups.remove(unknownSectionTitle)?.let { contactGroups.put(unknownSectionTitle, it) }
adapter.items = contactGroups.flatMap { entry -> listOf(ContactListItem.Header(entry.key)) + entry.value } adapter.items = contactGroups.flatMap { entry -> listOf(ContactListItem.Header(entry.key)) + entry.value }

View File

@ -3,28 +3,50 @@ package org.thoughtcrime.securesms.conversation.v2
import android.Manifest import android.Manifest
import android.animation.FloatEvaluator import android.animation.FloatEvaluator
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.content.* import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.res.Resources import android.content.res.Resources
import android.database.Cursor import android.database.Cursor
import android.graphics.Rect import android.graphics.Rect
import android.graphics.Typeface import android.graphics.Typeface
import android.net.Uri import android.net.Uri
import android.os.* import android.os.AsyncTask
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore import android.provider.MediaStore
import android.text.SpannableStringBuilder
import android.text.SpannedString
import android.text.TextUtils import android.text.TextUtils
import android.text.style.StyleSpan
import android.util.Pair import android.util.Pair
import android.util.TypedValue import android.util.TypedValue
import android.view.* import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.RelativeLayout import android.widget.RelativeLayout
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.DimenRes import androidx.annotation.DimenRes
import androidx.appcompat.app.AlertDialog import androidx.core.text.set
import androidx.core.text.toSpannable
import androidx.core.view.drawToBitmap
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.loader.app.LoaderManager import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -32,6 +54,11 @@ import androidx.recyclerview.widget.RecyclerView
import com.annimon.stream.Stream import com.annimon.stream.Stream
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
@ -58,8 +85,12 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.* import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.MediaTypes
import org.session.libsession.utilities.Stub
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.concurrent.SimpleTask import org.session.libsession.utilities.concurrent.SimpleTask
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.RecipientModifiedListener import org.session.libsession.utilities.recipients.RecipientModifiedListener
@ -70,7 +101,6 @@ import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.hexEncodedPrivateKey import org.session.libsignal.utilities.hexEncodedPrivateKey
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.ExpirationDialog
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.attachments.ScreenshotObserver
import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.audio.AudioRecorder
@ -78,6 +108,10 @@ import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.sele
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_REPLY
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_RESEND
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE
import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog
@ -92,10 +126,24 @@ import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate
import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar
import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel
import org.thoughtcrime.securesms.conversation.v2.utilities.* import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities
import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MnemonicUtilities import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.database.* import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.ReactionDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
@ -108,14 +156,31 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel.LinkPreviewState import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel.LinkPreviewState
import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaSendActivity import org.thoughtcrime.securesms.mediasend.MediaSendActivity
import org.thoughtcrime.securesms.mms.* import org.thoughtcrime.securesms.mms.AudioSlide
import org.thoughtcrime.securesms.mms.GifSlide
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.ImageSlide
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.mms.VideoSlide
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment
import org.thoughtcrime.securesms.util.* import org.thoughtcrime.securesms.showExpirationDialog
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SaveAttachmentTask
import org.thoughtcrime.securesms.util.isScrolledToBottom
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.toPx
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.* import java.util.Locale
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject import javax.inject.Inject
@ -184,11 +249,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
it it
} }
val recipient = Recipient.from(this, address, false) val recipient = Recipient.from(this, address, false)
threadId = threadDb.getOrCreateThreadIdFor(recipient) threadId = storage.getOrCreateThreadIdFor(recipient.address)
} }
} ?: finish() } ?: finish()
} }
viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair()) viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair(), contentResolver)
} }
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
private var unreadCount = 0 private var unreadCount = 0
@ -209,6 +274,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val searchViewModel: SearchViewModel by viewModels() val searchViewModel: SearchViewModel by viewModels()
var searchViewItem: MenuItem? = null var searchViewItem: MenuItem? = null
private val bufferedLastSeenChannel = Channel<Long>(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private var emojiPickerVisible = false private var emojiPickerVisible = false
private val isScrolledToBottom: Boolean private val isScrolledToBottom: Boolean
@ -228,11 +294,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english) MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english)
} }
// There is a bug when initially joining a community where all messages will immediately be marked
// as read if we reverse the message list so this is now hard-coded to false
private val reverseMessageList = false
private val adapter by lazy { private val adapter by lazy {
val cursor = mmsSmsDb.getConversation(viewModel.threadId, !isIncomingMessageRequestThread()) val cursor = mmsSmsDb.getConversation(viewModel.threadId, reverseMessageList)
val adapter = ConversationAdapter( val adapter = ConversationAdapter(
this, this,
cursor, cursor,
storage.getLastSeen(viewModel.threadId),
reverseMessageList,
onItemPress = { message, position, view, event -> onItemPress = { message, position, view, event ->
handlePress(message, position, view, event) handlePress(message, position, view, event)
}, },
@ -274,6 +346,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_camera_24, hasOpaqueBackground = true) } private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_camera_24, hasOpaqueBackground = true) }
private val messageToScrollTimestamp = AtomicLong(-1) private val messageToScrollTimestamp = AtomicLong(-1)
private val messageToScrollAuthor = AtomicReference<Address?>(null) private val messageToScrollAuthor = AtomicReference<Address?>(null)
private val firstLoad = AtomicBoolean(true)
private lateinit var reactionDelegate: ConversationReactionDelegate private lateinit var reactionDelegate: ConversationReactionDelegate
private val reactWithAnyEmojiStartPage = -1 private val reactWithAnyEmojiStartPage = -1
@ -318,28 +391,31 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpUiStateObserver() setUpUiStateObserver()
binding!!.scrollToBottomButton.setOnClickListener { binding!!.scrollToBottomButton.setOnClickListener {
val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener
val targetPosition = if (reverseMessageList) 0 else adapter.itemCount
if (layoutManager.isSmoothScrolling) { if (layoutManager.isSmoothScrolling) {
binding?.conversationRecyclerView?.scrollToPosition(0) binding?.conversationRecyclerView?.scrollToPosition(targetPosition)
} else { } else {
// It looks like 'smoothScrollToPosition' will actually load all intermediate items in // It looks like 'smoothScrollToPosition' will actually load all intermediate items in
// order to do the scroll, this can be very slow if there are a lot of messages so // order to do the scroll, this can be very slow if there are a lot of messages so
// instead we check the current position and if there are more than 10 items to scroll // instead we check the current position and if there are more than 10 items to scroll
// we jump instantly to the 10th item and scroll from there (this should happen quick // we jump instantly to the 10th item and scroll from there (this should happen quick
// enough to give a similar scroll effect without having to load everything) // enough to give a similar scroll effect without having to load everything)
val position = layoutManager.findFirstVisibleItemPosition() // val position = if (reverseMessageList) layoutManager.findFirstVisibleItemPosition() else layoutManager.findLastVisibleItemPosition()
if (position > 10) { // val targetBuffer = if (reverseMessageList) 10 else Math.max(0, (adapter.itemCount - 1) - 10)
binding?.conversationRecyclerView?.scrollToPosition(10) // if (position > targetBuffer) {
} // binding?.conversationRecyclerView?.scrollToPosition(targetBuffer)
// }
binding?.conversationRecyclerView?.post { binding?.conversationRecyclerView?.post {
binding?.conversationRecyclerView?.smoothScrollToPosition(0) binding?.conversationRecyclerView?.smoothScrollToPosition(targetPosition)
} }
} }
} }
updateUnreadCountIndicator() updateUnreadCountIndicator()
updateSubtitle() updateSubtitle()
updatePlaceholder()
setUpBlockedBanner() setUpBlockedBanner()
binding!!.searchBottomBar.setEventListener(this) binding!!.searchBottomBar.setEventListener(this)
updateSendAfterApprovalText() updateSendAfterApprovalText()
@ -349,20 +425,30 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val weakActivity = WeakReference(this) val weakActivity = WeakReference(this)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId)
// Note: We are accessing the `adapter` property because we want it to be loaded on // Note: We are accessing the `adapter` property because we want it to be loaded on
// the background thread to avoid blocking the UI thread and potentially hanging when // the background thread to avoid blocking the UI thread and potentially hanging when
// transitioning to the activity // transitioning to the activity
weakActivity.get()?.adapter ?: return@launch weakActivity.get()?.adapter ?: return@launch
// 'Get' instead of 'GetAndSet' here because we want to trigger the highlight in 'onFirstLoad'
// by triggering 'jumpToMessage' using these values
val messageTimestamp = messageToScrollTimestamp.get()
val author = messageToScrollAuthor.get()
val targetPosition = if (author != null && messageTimestamp >= 0) mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, messageTimestamp, author, reverseMessageList) else -1
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
setUpRecyclerView() setUpRecyclerView()
setUpTypingObserver() setUpTypingObserver()
setUpRecipientObserver() setUpRecipientObserver()
getLatestOpenGroupInfoIfNeeded() getLatestOpenGroupInfoIfNeeded()
setUpSearchResultObserver() setUpSearchResultObserver()
scrollToFirstUnreadMessageIfNeeded()
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
binding?.conversationRecyclerView?.scrollToPosition(targetPosition)
}
else {
scrollToFirstUnreadMessageIfNeeded(true)
}
} }
} }
@ -370,16 +456,25 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
ViewUtil.findStubById(this, R.id.conversation_reaction_scrubber_stub) ViewUtil.findStubById(this, R.id.conversation_reaction_scrubber_stub)
reactionDelegate = ConversationReactionDelegate(reactionOverlayStub) reactionDelegate = ConversationReactionDelegate(reactionOverlayStub)
reactionDelegate.setOnReactionSelectedListener(this) reactionDelegate.setOnReactionSelectedListener(this)
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
// only update the conversation every 3 seconds maximum
// channel is rendezvous and shouldn't block on try send calls as often as we want
val bufferedFlow = bufferedLastSeenChannel.consumeAsFlow()
bufferedFlow.filter {
it > storage.getLastSeen(viewModel.threadId)
}.collectLatest { latestMessageRead ->
withContext(Dispatchers.IO) {
storage.markConversationAsRead(viewModel.threadId, latestMessageRead)
}
}
}
}
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId) ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId)
val recipient = viewModel.recipient ?: return
lifecycleScope.launch(Dispatchers.IO) {
threadDb.markAllAsRead(viewModel.threadId, recipient.isOpenGroupRecipient)
}
contentResolver.registerContentObserver( contentResolver.registerContentObserver(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
@ -406,23 +501,45 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
push(intent, false) push(intent, false)
} }
override fun showDialog(baseDialog: BaseDialog, tag: String?) { override fun showDialog(dialogFragment: DialogFragment, tag: String?) {
baseDialog.show(supportFragmentManager, tag) dialogFragment.show(supportFragmentManager, tag)
} }
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<Cursor> { override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<Cursor> {
return ConversationLoader(viewModel.threadId, !isIncomingMessageRequestThread(), this@ConversationActivityV2) return ConversationLoader(viewModel.threadId, reverseMessageList, this@ConversationActivityV2)
} }
override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) { override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) {
val oldCount = adapter.itemCount
val newCount = cursor?.count ?: 0
adapter.changeCursor(cursor) adapter.changeCursor(cursor)
if (cursor != null) { if (cursor != null) {
val messageTimestamp = messageToScrollTimestamp.getAndSet(-1) val messageTimestamp = messageToScrollTimestamp.getAndSet(-1)
val author = messageToScrollAuthor.getAndSet(null) val author = messageToScrollAuthor.getAndSet(null)
val initialUnreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId)
// Update the unreadCount value to be loaded from the database since we got a new message
if (firstLoad.get() || oldCount != newCount || initialUnreadCount != unreadCount) {
// Update the unreadCount value to be loaded from the database since we got a new
// message (we need to store it in a local variable as it can get overwritten on
// another thread before the 'firstLoad.getAndSet(false)' case below)
unreadCount = initialUnreadCount
updateUnreadCountIndicator()
}
if (author != null && messageTimestamp >= 0) { if (author != null && messageTimestamp >= 0) {
jumpToMessage(author, messageTimestamp, null) jumpToMessage(author, messageTimestamp, firstLoad.get(), null)
}
else if (firstLoad.getAndSet(false)) {
scrollToFirstUnreadMessageIfNeeded(true)
handleRecyclerViewScrolled()
}
else if (oldCount != newCount) {
handleRecyclerViewScrolled()
} }
} }
updatePlaceholder()
} }
override fun onLoaderReset(cursor: Loader<Cursor>) { override fun onLoaderReset(cursor: Loader<Cursor>) {
@ -432,7 +549,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// called from onCreate // called from onCreate
private fun setUpRecyclerView() { private fun setUpRecyclerView() {
binding!!.conversationRecyclerView.adapter = adapter binding!!.conversationRecyclerView.adapter = adapter
val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, !isIncomingMessageRequestThread()) val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, reverseMessageList)
binding!!.conversationRecyclerView.layoutManager = layoutManager binding!!.conversationRecyclerView.layoutManager = layoutManager
// Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will) // Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will)
LoaderManager.getInstance(this).restartLoader(0, null, this) LoaderManager.getInstance(this).restartLoader(0, null, this)
@ -441,6 +558,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
handleRecyclerViewScrolled() handleRecyclerViewScrolled()
} }
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
}
}) })
binding!!.conversationRecyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> binding!!.conversationRecyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
@ -467,10 +588,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
R.dimen.small_profile_picture_size R.dimen.small_profile_picture_size
} }
val size = resources.getDimension(sizeID).roundToInt() val size = resources.getDimension(sizeID).roundToInt()
binding.toolbarContent.profilePictureView.root.layoutParams = LinearLayout.LayoutParams(size, size) binding.toolbarContent.profilePictureView.layoutParams = LinearLayout.LayoutParams(size, size)
binding.toolbarContent.profilePictureView.root.glide = glide
MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this) MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this)
val profilePictureView = binding.toolbarContent.profilePictureView.root val profilePictureView = binding.toolbarContent.profilePictureView
viewModel.recipient?.let(profilePictureView::update) viewModel.recipient?.let(profilePictureView::update)
} }
@ -576,7 +696,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name)
binding?.blockedBanner?.isVisible = recipient.isBlocked binding?.blockedBanner?.isVisible = recipient.isBlocked
binding?.blockedBanner?.setOnClickListener { viewModel.unblock(this@ConversationActivityV2) } binding?.blockedBanner?.setOnClickListener { viewModel.unblock() }
} }
private fun setUpLinkPreviewObserver() { private fun setUpLinkPreviewObserver() {
@ -609,15 +729,37 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (uiState.isMessageRequestAccepted == true) { if (uiState.isMessageRequestAccepted == true) {
binding?.messageRequestBar?.visibility = View.GONE binding?.messageRequestBar?.visibility = View.GONE
} }
if (!uiState.conversationExists && !isFinishing) {
// Conversation should be deleted now, just go back
finish()
}
} }
} }
} }
private fun scrollToFirstUnreadMessageIfNeeded() { private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false, shouldHighlight: Boolean = false): Int {
val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(viewModel.threadId).first() val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(viewModel.threadId).first()
val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return -1
if (lastSeenItemPosition <= 3) { return }
// If this is triggered when first opening a conversation then we want to position the top
// of the first unread message in the middle of the screen
if (isFirstLoad && !reverseMessageList) {
layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2))
if (shouldHighlight) { highlightViewAtPosition(lastSeenItemPosition) }
return lastSeenItemPosition
}
if (lastSeenItemPosition <= 3) { return lastSeenItemPosition }
binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition)
return lastSeenItemPosition
}
private fun highlightViewAtPosition(position: Int) {
binding?.conversationRecyclerView?.post {
(layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight()
}
} }
override fun onPrepareOptionsMenu(menu: Menu): Boolean { override fun onPrepareOptionsMenu(menu: Menu): Boolean {
@ -658,7 +800,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
updateSendAfterApprovalText() updateSendAfterApprovalText()
showOrHideInputIfNeeded() showOrHideInputIfNeeded()
binding?.toolbarContent?.profilePictureView?.root?.update(threadRecipient) binding?.toolbarContent?.profilePictureView?.update(threadRecipient)
binding?.toolbarContent?.conversationTitleView?.text = when { binding?.toolbarContent?.conversationTitleView?.text = when {
threadRecipient.isLocalNumber -> getString(R.string.note_to_self) threadRecipient.isLocalNumber -> getString(R.string.note_to_self)
else -> threadRecipient.toShortString() else -> threadRecipient.toShortString()
@ -701,11 +843,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun acceptMessageRequest() { private fun acceptMessageRequest() {
binding?.messageRequestBar?.isVisible = false binding?.messageRequestBar?.isVisible = false
binding?.conversationRecyclerView?.layoutManager =
LinearLayoutManager(this, LinearLayoutManager.VERTICAL, true)
adapter.notifyDataSetChanged()
viewModel.acceptMessageRequest() viewModel.acceptMessageRequest()
LoaderManager.getInstance(this).restartLoader(0, null, this)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2)
} }
@ -903,17 +1042,60 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
private fun handleRecyclerViewScrolled() { private fun handleRecyclerViewScrolled() {
// FIXME: Checking isScrolledToBottom is a quick fix for an issue where the
// typing indicator overlays the recycler view when scrolled up
val binding = binding ?: return val binding = binding ?: return
val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible
binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom
showScrollToBottomButtonIfApplicable() showScrollToBottomButtonIfApplicable()
val firstVisiblePosition = layoutManager?.findFirstVisibleItemPosition() ?: -1 val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition()
unreadCount = min(unreadCount, firstVisiblePosition).coerceAtLeast(0) val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION
if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) {
val visibleItemTimestamp = adapter.getTimestampForItemAt(targetVisiblePosition)
if (visibleItemTimestamp != null) {
bufferedLastSeenChannel.trySend(visibleItemTimestamp)
}
}
if (reverseMessageList) {
unreadCount = min(unreadCount, targetVisiblePosition).coerceAtLeast(0)
}
else {
val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() }
?: RecyclerView.NO_POSITION
unreadCount = min(unreadCount, layoutUnreadCount).coerceAtLeast(0)
}
updateUnreadCountIndicator() updateUnreadCountIndicator()
} }
private fun updatePlaceholder() {
val recipient = viewModel.recipient
?: return Log.w("Loki", "recipient was null in placeholder update")
val binding = binding ?: return
val openGroup = viewModel.openGroup
val (textResource, insertParam) = when {
recipient.isLocalNumber -> R.string.activity_conversation_empty_state_note_to_self to null
openGroup != null && !openGroup.canWrite -> R.string.activity_conversation_empty_state_read_only to recipient.toShortString()
else -> R.string.activity_conversation_empty_state_default to recipient.toShortString()
}
val showPlaceholder = adapter.itemCount == 0
binding.placeholderText.isVisible = showPlaceholder
if (showPlaceholder) {
if (insertParam != null) {
val span = getText(textResource) as SpannedString
val annotations = span.getSpans(0, span.length, StyleSpan::class.java)
val boldSpan = annotations.first()
val spannedParam = insertParam.toSpannable()
spannedParam[0 until spannedParam.length] = StyleSpan(boldSpan.style)
val originalStart = span.getSpanStart(boldSpan)
val originalEnd = span.getSpanEnd(boldSpan)
val newString = SpannableStringBuilder(span)
.replace(originalStart, originalEnd, spannedParam)
binding.placeholderText.text = newString
} else {
binding.placeholderText.setText(textResource)
}
}
}
private fun showScrollToBottomButtonIfApplicable() { private fun showScrollToBottomButtonIfApplicable() {
binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0 binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0
} }
@ -965,21 +1147,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
override fun block(deleteThread: Boolean) { override fun block(deleteThread: Boolean) {
val title = R.string.RecipientPreferenceActivity_block_this_contact_question showSessionDialog {
val message = R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact title(R.string.RecipientPreferenceActivity_block_this_contact_question)
val dialog = AlertDialog.Builder(this) text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact)
.setTitle(title) destructiveButton(R.string.RecipientPreferenceActivity_block, R.string.AccessibilityId_block_confirm) {
.setMessage(message) viewModel.block()
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.RecipientPreferenceActivity_block) { _, _ ->
viewModel.block(this@ConversationActivityV2)
if (deleteThread) { if (deleteThread) {
viewModel.deleteThread() viewModel.deleteThread()
finish() finish()
} }
}.show() }
val button = dialog.getButton(DialogInterface.BUTTON_POSITIVE) cancelButton()
button.setContentDescription("Confirm block") }
} }
override fun copySessionID(sessionId: String) { override fun copySessionID(sessionId: String) {
@ -1006,28 +1185,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val group = groupDb.getGroup(thread.address.toGroupString()).orNull() val group = groupDb.getGroup(thread.address.toGroupString()).orNull()
if (group?.isActive == false) { return } if (group?.isActive == false) { return }
} }
ExpirationDialog.show(this, thread.expireMessages) { expirationTime: Int -> showExpirationDialog(thread.expireMessages) { expirationTime ->
recipientDb.setExpireMessages(thread, expirationTime) storage.setExpirationTimer(thread.address.serialize(), expirationTime)
val message = ExpirationTimerUpdate(expirationTime) val message = ExpirationTimerUpdate(expirationTime)
message.recipient = thread.address.serialize() message.recipient = thread.address.serialize()
message.sentTimestamp = SnodeAPI.nowWithOffset message.sentTimestamp = SnodeAPI.nowWithOffset
val expiringMessageManager = ApplicationContext.getInstance(this).expiringMessageManager ApplicationContext.getInstance(this).expiringMessageManager.setExpirationTimer(message)
expiringMessageManager.setExpirationTimer(message)
MessageSender.send(message, thread.address) MessageSender.send(message, thread.address)
invalidateOptionsMenu() invalidateOptionsMenu()
} }
} }
override fun unblock() { override fun unblock() {
val title = R.string.ConversationActivity_unblock_this_contact_question showSessionDialog {
val message = R.string.ConversationActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact title(R.string.ConversationActivity_unblock_this_contact_question)
AlertDialog.Builder(this) text(R.string.ConversationActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact)
.setTitle(title) destructiveButton(
.setMessage(message) R.string.ConversationActivity_unblock,
.setNegativeButton(android.R.string.cancel, null) R.string.AccessibilityId_block_confirm
.setPositiveButton(R.string.ConversationActivity_unblock) { _, _ -> ) { viewModel.unblock() }
viewModel.unblock(this@ConversationActivityV2) cancelButton()
}.show() }
} }
// `position` is the adapter position; not the visual position // `position` is the adapter position; not the visual position
@ -1371,11 +1549,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
return return
} }
val binding = binding ?: return val binding = binding ?: return
if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) { val sentMessageInfo = if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) {
sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview) sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview)
} else { } else {
sendTextOnlyMessage() sendTextOnlyMessage()
} }
// Jump to the newly sent message once it gets added
if (sentMessageInfo != null) {
messageToScrollAuthor.set(sentMessageInfo.first)
messageToScrollTimestamp.set(sentMessageInfo.second)
}
} }
override fun commitInputContent(contentUri: Uri) { override fun commitInputContent(contentUri: Uri) {
@ -1393,19 +1577,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false) { private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair<Address, Long>? {
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return null
val sentTimestamp = SnodeAPI.nowWithOffset
processMessageRequestApproval() processMessageRequestApproval()
val text = getMessageBody() val text = getMessageBody()
val userPublicKey = textSecurePreferences.getLocalNumber() val userPublicKey = textSecurePreferences.getLocalNumber()
val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey)
if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) { if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) {
val dialog = SendSeedDialog { sendTextOnlyMessage(true) } val dialog = SendSeedDialog { sendTextOnlyMessage(true) }
return dialog.show(supportFragmentManager, "Send Seed Dialog") dialog.show(supportFragmentManager, "Send Seed Dialog")
return null
} }
// Create the message // Create the message
val message = VisibleMessage() val message = VisibleMessage()
message.sentTimestamp = SnodeAPI.nowWithOffset message.sentTimestamp = sentTimestamp
message.text = text message.text = text
val outgoingTextMessage = OutgoingTextMessage.from(message, recipient) val outgoingTextMessage = OutgoingTextMessage.from(message, recipient)
// Clear the input bar // Clear the input bar
@ -1422,14 +1608,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
MessageSender.send(message, recipient.address) MessageSender.send(message, recipient.address)
// Send a typing stopped message // Send a typing stopped message
ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId)
return Pair(recipient.address, sentTimestamp)
} }
private fun sendAttachments(attachments: List<Attachment>, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null) { private fun sendAttachments(attachments: List<Attachment>, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null): Pair<Address, Long>? {
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return null
val sentTimestamp = SnodeAPI.nowWithOffset
processMessageRequestApproval() processMessageRequestApproval()
// Create the message // Create the message
val message = VisibleMessage() val message = VisibleMessage()
message.sentTimestamp = SnodeAPI.nowWithOffset message.sentTimestamp = sentTimestamp
message.text = body message.text = body
val quote = quotedMessage?.let { val quote = quotedMessage?.let {
val quotedAttachments = (it as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: listOf() val quotedAttachments = (it as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: listOf()
@ -1463,28 +1651,28 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
MessageSender.send(message, recipient.address, attachments, quote, linkPreview) MessageSender.send(message, recipient.address, attachments, quote, linkPreview)
// Send a typing stopped message // Send a typing stopped message
ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId)
return Pair(recipient.address, sentTimestamp)
} }
private fun showGIFPicker() { private fun showGIFPicker() {
val hasSeenGIFMetaDataWarning: Boolean = textSecurePreferences.hasSeenGIFMetaDataWarning() val hasSeenGIFMetaDataWarning: Boolean = textSecurePreferences.hasSeenGIFMetaDataWarning()
if (!hasSeenGIFMetaDataWarning) { if (!hasSeenGIFMetaDataWarning) {
val builder = AlertDialog.Builder(this) showSessionDialog {
builder.setTitle(R.string.giphy_permission_title) title(R.string.giphy_permission_title)
builder.setMessage(R.string.giphy_permission_message) text(R.string.giphy_permission_message)
builder.setPositiveButton(R.string.continue_2) { dialog: DialogInterface, _: Int -> button(R.string.continue_2) {
textSecurePreferences.setHasSeenGIFMetaDataWarning() textSecurePreferences.setHasSeenGIFMetaDataWarning()
AttachmentManager.selectGif(this, PICK_GIF) selectGif()
dialog.dismiss() }
cancelButton()
} }
builder.setNegativeButton(R.string.cancel) { dialog: DialogInterface, _: Int ->
dialog.dismiss()
}
builder.create().show()
} else { } else {
AttachmentManager.selectGif(this, PICK_GIF) selectGif()
} }
} }
private fun selectGif() = AttachmentManager.selectGif(this, PICK_GIF)
private fun showDocumentPicker() { private fun showDocumentPicker() {
AttachmentManager.selectDocument(this, PICK_DOCUMENT) AttachmentManager.selectDocument(this, PICK_DOCUMENT)
} }
@ -1584,7 +1772,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
showVoiceMessageUI() showVoiceMessageUI()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
audioRecorder.startRecording() audioRecorder.startRecording()
stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 60000) // Limit voice messages to 1 minute each stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 300000) // Limit voice messages to 5 minute each
} else { } else {
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.RECORD_AUDIO) .request(Manifest.permission.RECORD_AUDIO)
@ -1631,35 +1819,23 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null } val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null }
if (recipient.isOpenGroupRecipient) { if (recipient.isOpenGroupRecipient) {
val messageCount = 1 val messageCount = 1
val builder = AlertDialog.Builder(this)
builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) showSessionDialog {
builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
builder.setCancelable(true) text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
builder.setPositiveButton(R.string.delete) { _, _ -> button(R.string.delete) { messages.forEach(viewModel::deleteForEveryone); endActionMode() }
for (message in messages) { cancelButton { endActionMode() }
viewModel.deleteForEveryone(message)
}
endActionMode()
} }
builder.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
endActionMode()
}
builder.show()
} else if (allSentByCurrentUser && allHasHash) { } else if (allSentByCurrentUser && allHasHash) {
val bottomSheet = DeleteOptionsBottomSheet() val bottomSheet = DeleteOptionsBottomSheet()
bottomSheet.recipient = recipient bottomSheet.recipient = recipient
bottomSheet.onDeleteForMeTapped = { bottomSheet.onDeleteForMeTapped = {
for (message in messages) { messages.forEach(viewModel::deleteLocally)
viewModel.deleteLocally(message)
}
bottomSheet.dismiss() bottomSheet.dismiss()
endActionMode() endActionMode()
} }
bottomSheet.onDeleteForEveryoneTapped = { bottomSheet.onDeleteForEveryoneTapped = {
for (message in messages) { messages.forEach(viewModel::deleteForEveryone)
viewModel.deleteForEveryone(message)
}
bottomSheet.dismiss() bottomSheet.dismiss()
endActionMode() endActionMode()
} }
@ -1670,54 +1846,32 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
bottomSheet.show(supportFragmentManager, bottomSheet.tag) bottomSheet.show(supportFragmentManager, bottomSheet.tag)
} else { } else {
val messageCount = 1 val messageCount = 1
val builder = AlertDialog.Builder(this)
builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) showSessionDialog {
builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
builder.setCancelable(true) text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
builder.setPositiveButton(R.string.delete) { _, _ -> button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() }
for (message in messages) { cancelButton(::endActionMode)
viewModel.deleteLocally(message)
}
endActionMode()
} }
builder.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
endActionMode()
}
builder.show()
} }
} }
override fun banUser(messages: Set<MessageRecord>) { override fun banUser(messages: Set<MessageRecord>) {
val builder = AlertDialog.Builder(this) showSessionDialog {
builder.setTitle(R.string.ConversationFragment_ban_selected_user) title(R.string.ConversationFragment_ban_selected_user)
builder.setMessage("This will ban the selected user from this room. It won't ban them from other rooms.") text("This will ban the selected user from this room. It won't ban them from other rooms.")
builder.setCancelable(true) button(R.string.ban) { viewModel.banUser(messages.first().individualRecipient); endActionMode() }
builder.setPositiveButton(R.string.ban) { _, _ -> cancelButton(::endActionMode)
viewModel.banUser(messages.first().individualRecipient)
endActionMode()
} }
builder.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
endActionMode()
}
builder.show()
} }
override fun banAndDeleteAll(messages: Set<MessageRecord>) { override fun banAndDeleteAll(messages: Set<MessageRecord>) {
val builder = AlertDialog.Builder(this) showSessionDialog {
builder.setTitle(R.string.ConversationFragment_ban_selected_user) title(R.string.ConversationFragment_ban_selected_user)
builder.setMessage("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.") text("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.")
builder.setCancelable(true) button(R.string.ban) { viewModel.banAndDeleteAll(messages.first().individualRecipient); endActionMode() }
builder.setPositiveButton(R.string.ban) { _, _ -> cancelButton(::endActionMode)
viewModel.banAndDeleteAll(messages.first().individualRecipient)
endActionMode()
} }
builder.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
endActionMode()
}
builder.show()
} }
override fun copyMessages(messages: Set<MessageRecord>) { override fun copyMessages(messages: Set<MessageRecord>) {
@ -1772,16 +1926,30 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
endActionMode() endActionMode()
} }
private val handleMessageDetail = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
val message = result.data?.extras?.getLong(MESSAGE_TIMESTAMP)
?.let(mmsSmsDb::getMessageForTimestamp)
val set = setOfNotNull(message)
when (result.resultCode) {
ON_REPLY -> reply(set)
ON_RESEND -> resendMessage(set)
ON_DELETE -> deleteMessages(set)
}
}
override fun showMessageDetail(messages: Set<MessageRecord>) { override fun showMessageDetail(messages: Set<MessageRecord>) {
val intent = Intent(this, MessageDetailActivity::class.java) Intent(this, MessageDetailActivity::class.java)
intent.putExtra(MessageDetailActivity.MESSAGE_TIMESTAMP, messages.first().timestamp) .apply { putExtra(MESSAGE_TIMESTAMP, messages.first().timestamp) }
push(intent) .let { handleMessageDetail.launch(it) }
endActionMode() endActionMode()
} }
override fun saveAttachment(messages: Set<MessageRecord>) { override fun saveAttachment(messages: Set<MessageRecord>) {
val message = messages.first() as MmsMessageRecord val message = messages.first() as MmsMessageRecord
SaveAttachmentTask.showWarningDialog(this, { _, _ -> SaveAttachmentTask.showWarningDialog(this) {
Permissions.with(this) Permissions.with(this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.maxSdkVersion(Build.VERSION_CODES.P) .maxSdkVersion(Build.VERSION_CODES.P)
@ -1809,12 +1977,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
Toast.LENGTH_LONG).show() Toast.LENGTH_LONG).show()
} }
.execute() .execute()
}) }
} }
override fun reply(messages: Set<MessageRecord>) { override fun reply(messages: Set<MessageRecord>) {
val recipient = viewModel.recipient ?: return val recipient = viewModel.recipient ?: return
binding?.inputBar?.draftQuote(recipient, messages.first(), glide) messages.firstOrNull()?.let { binding?.inputBar?.draftQuote(recipient, it, glide) }
endActionMode() endActionMode()
} }
@ -1867,7 +2035,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (result == null) return@Observer if (result == null) return@Observer
if (result.getResults().isNotEmpty()) { if (result.getResults().isNotEmpty()) {
result.getResults()[result.position]?.let { result.getResults()[result.position]?.let {
jumpToMessage(it.messageRecipient.address, it.sentTimestampMs) { jumpToMessage(it.messageRecipient.address, it.sentTimestampMs, true) {
searchViewModel.onMissingResult() } searchViewModel.onMissingResult() }
} }
} }
@ -1904,15 +2072,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
this.searchViewModel.onMoveDown() this.searchViewModel.onMoveDown()
} }
private fun jumpToMessage(author: Address, timestamp: Long, onMessageNotFound: Runnable?) { private fun jumpToMessage(author: Address, timestamp: Long, highlight: Boolean, onMessageNotFound: Runnable?) {
SimpleTask.run(lifecycle, { SimpleTask.run(lifecycle, {
mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author) mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author, reverseMessageList)
}) { p: Int -> moveToMessagePosition(p, onMessageNotFound) } }) { p: Int -> moveToMessagePosition(p, highlight, onMessageNotFound) }
} }
private fun moveToMessagePosition(position: Int, onMessageNotFound: Runnable?) { private fun moveToMessagePosition(position: Int, highlight: Boolean, onMessageNotFound: Runnable?) {
if (position >= 0) { if (position >= 0) {
binding?.conversationRecyclerView?.scrollToPosition(position) binding?.conversationRecyclerView?.scrollToPosition(position)
if (highlight) {
runOnUiThread {
highlightViewAtPosition(position)
}
}
} else { } else {
onMessageNotFound?.run() onMessageNotFound?.run()
} }

View File

@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.database.Cursor import android.database.Cursor
@ -31,10 +30,15 @@ import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.showSessionDialog
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.min
class ConversationAdapter( class ConversationAdapter(
context: Context, context: Context,
cursor: Cursor, cursor: Cursor,
originalLastSeen: Long,
private val isReversed: Boolean,
private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit, private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit,
private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemSwipeToReply: (MessageRecord, Int) -> Unit,
private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit, private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit,
@ -52,6 +56,8 @@ class ConversationAdapter(
private val updateQueue = Channel<String>(1024, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val updateQueue = Channel<String>(1024, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val contactCache = SparseArray<Contact>(100) private val contactCache = SparseArray<Contact>(100)
private val contactLoadedCache = SparseBooleanArray(100) private val contactLoadedCache = SparseBooleanArray(100)
private val lastSeen = AtomicLong(originalLastSeen)
init { init {
lifecycleCoroutineScope.launch(IO) { lifecycleCoroutineScope.launch(IO) {
while (isActive) { while (isActive) {
@ -128,6 +134,7 @@ class ConversationAdapter(
searchQuery, searchQuery,
contact, contact,
senderId, senderId,
lastSeen.get(),
visibleMessageViewDelegate, visibleMessageViewDelegate,
onAttachmentNeedsDownload onAttachmentNeedsDownload
) )
@ -146,17 +153,15 @@ class ConversationAdapter(
viewHolder.view.bind(message, messageBefore) viewHolder.view.bind(message, messageBefore)
if (message.isCallLog && message.isFirstMissedCall) { if (message.isCallLog && message.isFirstMissedCall) {
viewHolder.view.setOnClickListener { viewHolder.view.setOnClickListener {
AlertDialog.Builder(context) context.showSessionDialog {
.setTitle(R.string.CallNotificationBuilder_first_call_title) title(R.string.CallNotificationBuilder_first_call_title)
.setMessage(R.string.CallNotificationBuilder_first_call_message) text(R.string.CallNotificationBuilder_first_call_message)
.setPositiveButton(R.string.activity_settings_title) { _, _ -> button(R.string.activity_settings_title) {
val intent = Intent(context, PrivacySettingsActivity::class.java) Intent(context, PrivacySettingsActivity::class.java)
context.startActivity(intent) .let(context::startActivity)
} }
.setNeutralButton(R.string.cancel) { d, _ -> cancelButton()
d.dismiss() }
}
.show()
} }
} else { } else {
viewHolder.view.setOnClickListener(null) viewHolder.view.setOnClickListener(null)
@ -185,14 +190,18 @@ class ConversationAdapter(
private fun getMessageBefore(position: Int, cursor: Cursor): MessageRecord? { private fun getMessageBefore(position: Int, cursor: Cursor): MessageRecord? {
// The message that's visually before the current one is actually after the current // The message that's visually before the current one is actually after the current
// one for the cursor because the layout is reversed // one for the cursor because the layout is reversed
if (!cursor.moveToPosition(position + 1)) { return null } if (isReversed && !cursor.moveToPosition(position + 1)) { return null }
if (!isReversed && !cursor.moveToPosition(position - 1)) { return null }
return messageDB.readerFor(cursor).current return messageDB.readerFor(cursor).current
} }
private fun getMessageAfter(position: Int, cursor: Cursor): MessageRecord? { private fun getMessageAfter(position: Int, cursor: Cursor): MessageRecord? {
// The message that's visually after the current one is actually before the current // The message that's visually after the current one is actually before the current
// one for the cursor because the layout is reversed // one for the cursor because the layout is reversed
if (!cursor.moveToPosition(position - 1)) { return null } if (isReversed && !cursor.moveToPosition(position - 1)) { return null }
if (!isReversed && !cursor.moveToPosition(position + 1)) { return null }
return messageDB.readerFor(cursor).current return messageDB.readerFor(cursor).current
} }
@ -219,11 +228,30 @@ class ConversationAdapter(
fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? { fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? {
val cursor = this.cursor val cursor = this.cursor
if (lastSeenTimestamp <= 0L || cursor == null || !isActiveCursor) return null if (cursor == null || !isActiveCursor) return null
if (lastSeenTimestamp == 0L) {
if (isReversed && cursor.moveToLast()) { return cursor.position }
if (!isReversed && cursor.moveToFirst()) { return cursor.position }
}
// Loop from the newest message to the oldest until we find one older (or equal to)
// the lastSeenTimestamp, then return that message index
for (i in 0 until itemCount) { for (i in 0 until itemCount) {
cursor.moveToPosition(i) if (isReversed) {
val message = messageDB.readerFor(cursor).current cursor.moveToPosition(i)
if (message.isOutgoing || message.dateReceived <= lastSeenTimestamp) { return i } val (outgoing, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor)
if (outgoing || dateSent <= lastSeenTimestamp) {
return i
}
}
else {
val index = ((itemCount - 1) - i)
cursor.moveToPosition(index)
val (outgoing, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor)
if (outgoing || dateSent <= lastSeenTimestamp) {
return min(itemCount - 1, (index + 1))
}
}
} }
return null return null
} }
@ -233,8 +261,8 @@ class ConversationAdapter(
if (timestamp <= 0L || cursor == null || !isActiveCursor) return null if (timestamp <= 0L || cursor == null || !isActiveCursor) return null
for (i in 0 until itemCount) { for (i in 0 until itemCount) {
cursor.moveToPosition(i) cursor.moveToPosition(i)
val message = messageDB.readerFor(cursor).current val (_, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor)
if (message.dateSent == timestamp) { return i } if (dateSent == timestamp) { return i }
} }
return null return null
} }
@ -243,4 +271,11 @@ class ConversationAdapter(
this.searchQuery = query this.searchQuery = query
notifyDataSetChanged() notifyDataSetChanged()
} }
fun getTimestampForItemAt(firstVisiblePosition: Int): Long? {
val cursor = this.cursor ?: return null
if (!cursor.moveToPosition(firstVisiblePosition)) return null
val message = messageDB.readerFor(cursor).current ?: return null
return message.timestamp
}
} }

View File

@ -695,9 +695,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_and_delete_all), () -> handleActionItemClicked(Action.BAN_AND_DELETE_ALL))); items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_and_delete_all), () -> handleActionItemClicked(Action.BAN_AND_DELETE_ALL)));
} }
// Message detail // Message detail
if (message.isFailed()) { items.add(new ActionItem(R.attr.menu_info_icon, getContext().getResources().getString(R.string.conversation_context__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
items.add(new ActionItem(R.attr.menu_info_icon, getContext().getResources().getString(R.string.conversation_context__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
}
// Resend // Resend
if (message.isFailed()) { if (message.isFailed()) {
items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND))); items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND)));

View File

@ -1,10 +1,10 @@
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.content.Context import android.content.ContentResolver
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.cash.copper.flow.observeQuery
import com.goterl.lazysodium.utils.KeyPair import com.goterl.lazysodium.utils.KeyPair
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -21,15 +21,16 @@ import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import java.util.UUID import java.util.UUID
class ConversationViewModel( class ConversationViewModel(
val threadId: Long, val threadId: Long,
val edKeyPair: KeyPair?, val edKeyPair: KeyPair?,
private val contentResolver: ContentResolver,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage private val storage: Storage
) : ViewModel() { ) : ViewModel() {
@ -37,7 +38,7 @@ class ConversationViewModel(
val showSendAfterApprovalText: Boolean val showSendAfterApprovalText: Boolean
get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false
private val _uiState = MutableStateFlow(ConversationUiState()) private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true))
val uiState: StateFlow<ConversationUiState> = _uiState val uiState: StateFlow<ConversationUiState> = _uiState
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce { private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
@ -61,6 +62,18 @@ class ConversationViewModel(
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
} }
init {
viewModelScope.launch(Dispatchers.IO) {
contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId))
.collect {
val recipientExists = storage.getRecipientForThread(threadId) != null
if (!recipientExists && _uiState.value.conversationExists) {
_uiState.update { it.copy(conversationExists = false) }
}
}
}
}
fun saveDraft(text: String) { fun saveDraft(text: String) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
repository.saveDraft(threadId, text) repository.saveDraft(threadId, text)
@ -81,27 +94,17 @@ class ConversationViewModel(
repository.inviteContacts(threadId, contacts) repository.inviteContacts(threadId, contacts)
} }
fun block(context: Context) { fun block() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action") val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action")
if (recipient.isContactRecipient) { if (recipient.isContactRecipient) {
repository.setBlocked(recipient, true) repository.setBlocked(recipient, true)
// TODO: Remove in UserConfig branch
GlobalScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
} }
} }
fun unblock(context: Context) { fun unblock() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action") val recipient = recipient ?: return Log.w("Loki", "Recipient was null for unblock action")
if (recipient.isContactRecipient) { if (recipient.isContactRecipient) {
repository.setBlocked(recipient, false) repository.setBlocked(recipient, false)
// TODO: Remove in UserConfig branch
GlobalScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
} }
} }
@ -198,19 +201,20 @@ class ConversationViewModel(
@dagger.assisted.AssistedFactory @dagger.assisted.AssistedFactory
interface AssistedFactory { interface AssistedFactory {
fun create(threadId: Long, edKeyPair: KeyPair?): Factory fun create(threadId: Long, edKeyPair: KeyPair?, contentResolver: ContentResolver): Factory
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
class Factory @AssistedInject constructor( class Factory @AssistedInject constructor(
@Assisted private val threadId: Long, @Assisted private val threadId: Long,
@Assisted private val edKeyPair: KeyPair?, @Assisted private val edKeyPair: KeyPair?,
@Assisted private val contentResolver: ContentResolver,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage private val storage: Storage
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ConversationViewModel(threadId, edKeyPair, repository, storage) as T return ConversationViewModel(threadId, edKeyPair, contentResolver, repository, storage) as T
} }
} }
} }
@ -219,7 +223,8 @@ data class UiMessage(val id: Long, val message: String)
data class ConversationUiState( data class ConversationUiState(
val uiMessages: List<UiMessage> = emptyList(), val uiMessages: List<UiMessage> = emptyList(),
val isMessageRequestAccepted: Boolean? = null val isMessageRequestAccepted: Boolean? = null,
val conversationExists: Boolean
) )
data class RetrieveOnce<T>(val retrieval: () -> T?) { data class RetrieveOnce<T>(val retrieval: () -> T?) {

View File

@ -1,99 +1,401 @@
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.LayoutInflater
import androidx.core.view.isVisible import android.view.MotionEvent.ACTION_UP
import androidx.activity.viewModels
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityMessageDetailBinding import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
import org.session.libsession.messaging.MessagingModuleConfiguration import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.ui.AppTheme
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.ui.Avatar
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.ui.CarouselNextButton
import java.text.SimpleDateFormat import org.thoughtcrime.securesms.ui.CarouselPrevButton
import java.util.* import org.thoughtcrime.securesms.ui.Cell
import org.thoughtcrime.securesms.ui.CellNoMargin
import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin
import org.thoughtcrime.securesms.ui.Divider
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.HorizontalPagerIndicator
import org.thoughtcrime.securesms.ui.ItemButton
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider
import org.thoughtcrime.securesms.ui.TitledText
import org.thoughtcrime.securesms.ui.blackAlpha40
import org.thoughtcrime.securesms.ui.colorDestructive
import org.thoughtcrime.securesms.ui.destructiveButtonColors
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MessageDetailActivity: PassphraseRequiredActionBarActivity() { class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
private lateinit var binding: ActivityMessageDetailBinding
var messageRecord: MessageRecord? = null
@Inject @Inject
lateinit var storage: Storage lateinit var storage: Storage
// region Settings private val viewModel: MessageDetailsViewModel by viewModels()
companion object { companion object {
// Extras // Extras
const val MESSAGE_TIMESTAMP = "message_timestamp" const val MESSAGE_TIMESTAMP = "message_timestamp"
const val ON_REPLY = 1
const val ON_RESEND = 2
const val ON_DELETE = 3
} }
// endregion
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready) super.onCreate(savedInstanceState, ready)
binding = ActivityMessageDetailBinding.inflate(layoutInflater)
setContentView(binding.root)
title = resources.getString(R.string.conversation_context__menu_message_details) title = resources.getString(R.string.conversation_context__menu_message_details)
val timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L)
// We only show this screen for messages fail to send, viewModel.timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L)
// so the author of the messages must be the current user.
val author = Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!) ComposeView(this)
messageRecord = DatabaseComponent.get(this).mmsSmsDatabase().getMessageFor(timestamp, author) ?: run { .apply { setContent { MessageDetailsScreen() } }
finish() .let(::setContentView)
return
} lifecycleScope.launch {
val threadId = messageRecord!!.threadId viewModel.eventFlow.collect {
val openGroup = storage.getOpenGroup(threadId) when (it) {
val blindedKey = openGroup?.let { group -> Event.Finish -> finish()
val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return@let null is Event.StartMediaPreview -> startActivity(
val blindingEnabled = storage.getServerCapabilities(group.server).contains(OpenGroupApi.Capability.BLIND.name.lowercase()) getPreviewIntent(this@MessageDetailActivity, it.args)
if (blindingEnabled) { )
SodiumUtilities.blindedKeyPair(group.publicKey, userEdKeyPair)?.publicKey?.asBytes }
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString }
} else null
}
updateContent()
binding.resendButton.setOnClickListener {
ResendMessageUtilities.resend(this, messageRecord!!, blindedKey)
finish()
} }
} }
fun updateContent() { @Composable
val dateLocale = Locale.getDefault() private fun MessageDetailsScreen() {
val dateFormatter: SimpleDateFormat = DateUtils.getDetailedDateFormatter(this, dateLocale) val state by viewModel.stateFlow.collectAsState()
binding.sentTime.text = dateFormatter.format(Date(messageRecord!!.dateSent)) AppTheme {
MessageDetails(
val errorMessage = DatabaseComponent.get(this).lokiMessageDatabase().getErrorMessage(messageRecord!!.getId()) state = state,
if (errorMessage != null) { onReply = { setResultAndFinish(ON_REPLY) },
binding.errorMessage.text = errorMessage onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } },
binding.resendContainer.isVisible = true onDelete = { setResultAndFinish(ON_DELETE) },
binding.errorContainer.isVisible = true onClickImage = { viewModel.onClickImage(it) },
} else { onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload,
binding.errorContainer.isVisible = false )
binding.resendContainer.isVisible = false
}
if (messageRecord!!.expiresIn <= 0 || messageRecord!!.expireStarted <= 0) {
binding.expiresContainer.visibility = View.GONE
} else {
binding.expiresContainer.visibility = View.VISIBLE
val elapsed = SnodeAPI.nowWithOffset - messageRecord!!.expireStarted
val remaining = messageRecord!!.expiresIn - elapsed
val duration = ExpirationUtil.getExpirationDisplayValue(this, Math.max((remaining / 1000).toInt(), 1))
binding.expiresIn.text = duration
} }
} }
}
private fun setResultAndFinish(code: Int) {
Bundle().apply { putLong(MESSAGE_TIMESTAMP, viewModel.timestamp) }
.let(Intent()::putExtras)
.let { setResult(code, it) }
finish()
}
}
@SuppressLint("ClickableViewAccessibility")
@Composable
fun MessageDetails(
state: MessageDetailsState,
onReply: () -> Unit = {},
onResend: (() -> Unit)? = null,
onDelete: () -> Unit = {},
onClickImage: (Int) -> Unit = {},
onAttachmentNeedsDownload: (Long, Long) -> Unit = { _, _ -> }
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
state.record?.let { message ->
AndroidView(
modifier = Modifier.padding(horizontal = 32.dp),
factory = {
ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(it)).mainContainerConstraint.apply {
bind(
message,
thread = state.thread!!,
onAttachmentNeedsDownload = onAttachmentNeedsDownload,
suppressThumbnails = true
)
setOnTouchListener { _, event ->
if (event.actionMasked == ACTION_UP) onContentClick(event)
true
}
}
}
)
}
Carousel(state.imageAttachments) { onClickImage(it) }
state.nonImageAttachmentFileDetails?.let { FileDetails(it) }
CellMetadata(state)
CellButtons(
onReply,
onResend,
onDelete,
)
}
}
@Composable
fun CellMetadata(
state: MessageDetailsState,
) {
state.apply {
if (listOfNotNull(sent, received, error, senderInfo).isEmpty()) return
CellWithPaddingAndMargin {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
TitledText(sent)
TitledText(received)
TitledErrorText(error)
senderInfo?.let {
TitledView(state.fromTitle) {
Row {
sender?.let { Avatar(it) }
TitledMonospaceText(it)
}
}
}
}
}
}
}
@Composable
fun CellButtons(
onReply: () -> Unit = {},
onResend: (() -> Unit)? = null,
onDelete: () -> Unit = {},
) {
Cell {
Column {
ItemButton(
stringResource(R.string.reply),
R.drawable.ic_message_details__reply,
onClick = onReply
)
Divider()
onResend?.let {
ItemButton(
stringResource(R.string.resend),
R.drawable.ic_message_details__refresh,
onClick = it
)
Divider()
}
ItemButton(
stringResource(R.string.delete),
R.drawable.ic_message_details__trash,
colors = destructiveButtonColors(),
onClick = onDelete
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Carousel(attachments: List<Attachment>, onClick: (Int) -> Unit) {
if (attachments.isEmpty()) return
val pagerState = rememberPagerState { attachments.size }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row {
CarouselPrevButton(pagerState)
Box(modifier = Modifier.weight(1f)) {
CellCarousel(pagerState, attachments, onClick)
HorizontalPagerIndicator(pagerState)
ExpandButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
) { onClick(pagerState.currentPage) }
}
CarouselNextButton(pagerState)
}
attachments.getOrNull(pagerState.currentPage)?.fileDetails?.let { FileDetails(it) }
}
}
@OptIn(
ExperimentalFoundationApi::class,
ExperimentalGlideComposeApi::class
)
@Composable
private fun CellCarousel(
pagerState: PagerState,
attachments: List<Attachment>,
onClick: (Int) -> Unit
) {
CellNoMargin {
HorizontalPager(state = pagerState) { i ->
GlideImage(
contentScale = ContentScale.Crop,
modifier = Modifier
.aspectRatio(1f)
.clickable { onClick(i) },
model = attachments[i].uri,
contentDescription = attachments[i].fileName ?: stringResource(id = R.string.image)
)
}
}
}
@Composable
fun ExpandButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
Surface(
shape = CircleShape,
color = blackAlpha40,
modifier = modifier,
contentColor = Color.White,
) {
Icon(
painter = painterResource(id = R.drawable.ic_expand),
contentDescription = stringResource(id = R.string.expand),
modifier = Modifier.clickable { onClick() },
)
}
}
@Preview
@Composable
fun PreviewMessageDetails(
@PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int
) {
PreviewTheme(themeResId) {
MessageDetails(
state = MessageDetailsState(
nonImageAttachmentFileDetails = listOf(
TitledText(R.string.message_details_header__file_id, "Screen Shot 2023-07-06 at 11.35.50 am.png"),
TitledText(R.string.message_details_header__file_type, "image/png"),
TitledText(R.string.message_details_header__file_size, "195.6kB"),
TitledText(R.string.message_details_header__resolution, "342x312"),
),
sent = TitledText(R.string.message_details_header__sent, "6:12 AM Tue, 09/08/2022"),
received = TitledText(R.string.message_details_header__received, "6:12 AM Tue, 09/08/2022"),
error = TitledText(R.string.message_details_header__error, "Message failed to send"),
senderInfo = TitledText("Connor", "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54"),
)
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun FileDetails(fileDetails: List<TitledText>) {
if (fileDetails.isEmpty()) return
CellWithPaddingAndMargin(padding = 0.dp) {
FlowRow(
modifier = Modifier.padding(vertical = 24.dp, horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
fileDetails.forEach {
BoxWithConstraints {
TitledText(
it,
modifier = Modifier
.widthIn(min = maxWidth.div(2))
.padding(horizontal = 12.dp)
.width(IntrinsicSize.Max)
)
}
}
}
}
}
@Composable
fun TitledErrorText(titledText: TitledText?) {
TitledText(
titledText,
valueStyle = LocalTextStyle.current.copy(color = colorDestructive)
)
}
@Composable
fun TitledMonospaceText(titledText: TitledText?) {
TitledText(
titledText,
valueStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace)
)
}
@Composable
fun TitledText(
titledText: TitledText?,
modifier: Modifier = Modifier,
valueStyle: TextStyle = LocalTextStyle.current,
) {
titledText?.apply {
TitledView(title, modifier) {
Text(text, style = valueStyle, modifier = Modifier.fillMaxWidth())
}
}
}
@Composable
fun TitledView(title: GetString, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Title(title)
content()
}
}
@Composable
fun Title(title: GetString) {
Text(title.string(), fontWeight = FontWeight.Bold)
}

View File

@ -0,0 +1,159 @@
package org.thoughtcrime.securesms.conversation.v2
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.Util
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.MediaPreviewArgs
import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.ImageSlide
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.TitledText
import java.util.Date
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel
class MessageDetailsViewModel @Inject constructor(
private val attachmentDb: AttachmentDatabase,
private val lokiMessageDatabase: LokiMessageDatabase,
private val mmsSmsDatabase: MmsSmsDatabase,
private val threadDb: ThreadDatabase,
) : ViewModel() {
private val state = MutableStateFlow(MessageDetailsState())
val stateFlow = state.asStateFlow()
private val event = Channel<Event>()
val eventFlow = event.receiveAsFlow()
var timestamp: Long = 0L
set(value) {
field = value
val record = mmsSmsDatabase.getMessageForTimestamp(timestamp)
if (record == null) {
viewModelScope.launch { event.send(Event.Finish) }
return
}
val mmsRecord = record as? MmsMessageRecord
state.value = record.run {
val slides = mmsRecord?.slideDeck?.slides ?: emptyList()
MessageDetailsState(
attachments = slides.map(::Attachment),
record = record,
sent = dateSent.let(::Date).toString().let { TitledText(R.string.message_details_header__sent, it) },
received = dateReceived.let(::Date).toString().let { TitledText(R.string.message_details_header__received, it) },
error = lokiMessageDatabase.getErrorMessage(id)?.let { TitledText(R.string.message_details_header__error, it) },
senderInfo = individualRecipient.run { name?.let { TitledText(it, address.serialize()) } },
sender = individualRecipient,
thread = threadDb.getRecipientForThreadId(threadId)!!,
)
}
}
private val Slide.details: List<TitledText>
get() = listOfNotNull(
fileName.orNull()?.let { TitledText(R.string.message_details_header__file_id, it) },
TitledText(R.string.message_details_header__file_type, asAttachment().contentType),
TitledText(R.string.message_details_header__file_size, Util.getPrettyFileSize(fileSize)),
takeIf { it is ImageSlide }
?.let(Slide::asAttachment)
?.run { "${width}x$height" }
?.let { TitledText(R.string.message_details_header__resolution, it) },
attachmentDb.duration(this)?.let { TitledText(R.string.message_details_header__duration, it) },
)
private fun AttachmentDatabase.duration(slide: Slide): String? =
slide.takeIf { it.hasAudio() }
?.run { asAttachment() as? DatabaseAttachment }
?.run { getAttachmentAudioExtras(attachmentId)?.durationMs }
?.takeIf { it > 0 }
?.let {
String.format(
"%01d:%02d",
TimeUnit.MILLISECONDS.toMinutes(it),
TimeUnit.MILLISECONDS.toSeconds(it) % 60
)
}
fun Attachment(slide: Slide): Attachment =
Attachment(slide.details, slide.fileName.orNull(), slide.uri, slide is ImageSlide)
fun onClickImage(index: Int) {
val state = state.value ?: return
val mmsRecord = state.mmsRecord ?: return
val slide = mmsRecord.slideDeck.slides[index] ?: return
// only open to downloaded images
if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) {
// Restart download here (on IO thread)
(slide.asAttachment() as? DatabaseAttachment)?.let { attachment ->
onAttachmentNeedsDownload(attachment.attachmentId.rowId, state.mmsRecord.getId())
}
}
if (slide.isInProgress) return
viewModelScope.launch {
MediaPreviewArgs(slide, state.mmsRecord, state.thread)
.let(Event::StartMediaPreview)
.let { event.send(it) }
}
}
fun onAttachmentNeedsDownload(attachmentId: Long, mmsId: Long) {
viewModelScope.launch(Dispatchers.IO) {
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId))
}
}
}
data class MessageDetailsState(
val attachments: List<Attachment> = emptyList(),
val imageAttachments: List<Attachment> = attachments.filter { it.hasImage },
val nonImageAttachmentFileDetails: List<TitledText>? = attachments.firstOrNull { !it.hasImage }?.fileDetails,
val record: MessageRecord? = null,
val mmsRecord: MmsMessageRecord? = record as? MmsMessageRecord,
val sent: TitledText? = null,
val received: TitledText? = null,
val error: TitledText? = null,
val senderInfo: TitledText? = null,
val sender: Recipient? = null,
val thread: Recipient? = null,
) {
val fromTitle = GetString(R.string.message_details_header__from)
}
data class Attachment(
val fileDetails: List<TitledText>,
val fileName: String?,
val uri: Uri?,
val hasImage: Boolean
)
sealed class Event {
object Finish: Event()
data class StartMediaPreview(val args: MediaPreviewArgs): Event()
}

View File

@ -28,11 +28,10 @@ class MentionCandidateView : LinearLayout {
private fun update() = with(binding) { private fun update() = with(binding) {
mentionCandidateNameTextView.text = mentionCandidate.displayName mentionCandidateNameTextView.text = mentionCandidate.displayName
profilePictureView.root.publicKey = mentionCandidate.publicKey profilePictureView.publicKey = mentionCandidate.publicKey
profilePictureView.root.displayName = mentionCandidate.displayName profilePictureView.displayName = mentionCandidate.displayName
profilePictureView.root.additionalPublicKey = null profilePictureView.additionalPublicKey = null
profilePictureView.root.glide = glide!! profilePictureView.update()
profilePictureView.root.update()
if (openGroupServer != null && openGroupRoom != null) { if (openGroupServer != null && openGroupRoom != null) {
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", mentionCandidate.publicKey) val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", mentionCandidate.publicKey)
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE

View File

@ -1,51 +1,42 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.app.Dialog
import android.content.Context import android.content.Context
import android.graphics.Typeface import android.graphics.Typeface
import android.os.Bundle
import android.text.Spannable import android.text.Spannable
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.view.LayoutInflater import androidx.fragment.app.DialogFragment
import androidx.appcompat.app.AlertDialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.DialogBlockedBinding import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
/** Shown upon sending a message to a user that's blocked. */ /** Shown upon sending a message to a user that's blocked. */
class BlockedDialog(private val recipient: Recipient, private val context: Context) : BaseDialog() { class BlockedDialog(private val recipient: Recipient, private val context: Context) : DialogFragment() {
override fun setContentView(builder: AlertDialog.Builder) { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val binding = DialogBlockedBinding.inflate(LayoutInflater.from(requireContext()))
val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase() val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase()
val sessionID = recipient.address.toString() val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID) val contact = contactDB.getContactWithSessionID(sessionID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val title = resources.getString(R.string.dialog_blocked_title, name)
binding.blockedTitleTextView.text = title
val explanation = resources.getString(R.string.dialog_blocked_explanation, name) val explanation = resources.getString(R.string.dialog_blocked_explanation, name)
val spannable = SpannableStringBuilder(explanation) val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(name) val startIndex = explanation.indexOf(name)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
binding.blockedExplanationTextView.text = spannable
binding.cancelButton.setOnClickListener { dismiss() } title(resources.getString(R.string.dialog_blocked_title, name))
binding.unblockButton.setOnClickListener { unblock() } text(spannable)
builder.setView(binding.root) button(R.string.ConversationActivity_unblock) { unblock() }
cancelButton { dismiss() }
} }
private fun unblock() { private fun unblock() {
DatabaseComponent.get(requireContext()).recipientDatabase().setBlocked(recipient, false) MessagingModuleConfiguration.shared.storage.setBlocked(listOf(recipient), false)
dismiss() dismiss()
// TODO: Remove in UserConfig branch
GlobalScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
} }
} }

View File

@ -1,19 +1,19 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.app.Dialog
import android.graphics.Typeface import android.graphics.Typeface
import android.os.Bundle
import android.text.Spannable import android.text.Spannable
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.view.LayoutInflater import androidx.fragment.app.DialogFragment
import androidx.appcompat.app.AlertDialog
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.DialogDownloadBinding
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.AttachmentDownloadJob
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import javax.inject.Inject import javax.inject.Inject
@ -21,25 +21,24 @@ import javax.inject.Inject
/** Shown when receiving media from a contact for the first time, to confirm that /** Shown when receiving media from a contact for the first time, to confirm that
* they are to be trusted and files sent by them are to be downloaded. */ * they are to be trusted and files sent by them are to be downloaded. */
@AndroidEntryPoint @AndroidEntryPoint
class DownloadDialog(private val recipient: Recipient) : BaseDialog() { class DownloadDialog(private val recipient: Recipient) : DialogFragment() {
@Inject lateinit var contactDB: SessionContactDatabase @Inject lateinit var contactDB: SessionContactDatabase
override fun setContentView(builder: AlertDialog.Builder) { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val binding = DialogDownloadBinding.inflate(LayoutInflater.from(requireContext()))
val sessionID = recipient.address.toString() val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID) val contact = contactDB.getContactWithSessionID(sessionID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val title = resources.getString(R.string.dialog_download_title, name) title(resources.getString(R.string.dialog_download_title, name))
binding.downloadTitleTextView.text = title
val explanation = resources.getString(R.string.dialog_download_explanation, name) val explanation = resources.getString(R.string.dialog_download_explanation, name)
val spannable = SpannableStringBuilder(explanation) val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(name) val startIndex = explanation.indexOf(name)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
binding.downloadExplanationTextView.text = spannable text(spannable)
binding.cancelButton.setOnClickListener { dismiss() }
binding.downloadButton.setOnClickListener { trust() } button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) { trust() }
builder.setView(binding.root) cancelButton { dismiss() }
} }
private fun trust() { private fun trust() {
@ -50,4 +49,4 @@ class DownloadDialog(private val recipient: Recipient) : BaseDialog() {
JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY) JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY)
dismiss() dismiss()
} }
} }

View File

@ -1,46 +1,42 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.app.Dialog
import android.graphics.Typeface import android.graphics.Typeface
import android.os.Bundle
import android.text.Spannable import android.text.Spannable
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment
import androidx.appcompat.app.AppCompatActivity
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.DialogJoinOpenGroupBinding
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.utilities.OpenGroupUrlParser import org.session.libsession.utilities.OpenGroupUrlParser
import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog import org.thoughtcrime.securesms.createSessionDialog
import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
/** Shown upon tapping an open group invitation. */ /** Shown upon tapping an open group invitation. */
class JoinOpenGroupDialog(private val name: String, private val url: String) : BaseDialog() { class JoinOpenGroupDialog(private val name: String, private val url: String) : DialogFragment() {
override fun setContentView(builder: AlertDialog.Builder) { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val binding = DialogJoinOpenGroupBinding.inflate(LayoutInflater.from(requireContext())) title(resources.getString(R.string.dialog_join_open_group_title, name))
val title = resources.getString(R.string.dialog_join_open_group_title, name)
binding.joinOpenGroupTitleTextView.text = title
val explanation = resources.getString(R.string.dialog_join_open_group_explanation, name) val explanation = resources.getString(R.string.dialog_join_open_group_explanation, name)
val spannable = SpannableStringBuilder(explanation) val spannable = SpannableStringBuilder(explanation)
val startIndex = explanation.indexOf(name) val startIndex = explanation.indexOf(name)
spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
binding.joinOpenGroupExplanationTextView.text = spannable text(spannable)
binding.cancelButton.setOnClickListener { dismiss() } cancelButton { dismiss() }
binding.joinButton.setOnClickListener { join() } button(R.string.open_group_invitation_view__join_accessibility_description) { join() }
builder.setView(binding.root)
} }
private fun join() { private fun join() {
val openGroup = OpenGroupUrlParser.parseUrl(url) val openGroup = OpenGroupUrlParser.parseUrl(url)
val activity = requireContext() as AppCompatActivity val activity = requireActivity()
ThreadUtils.queue { ThreadUtils.queue {
try { try {
OpenGroupManager.add(openGroup.server, openGroup.room, openGroup.serverPublicKey, activity) openGroup.apply { OpenGroupManager.add(server, room, serverPublicKey, activity) }
MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server) MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server, openGroup.room)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity)
} catch (e: Exception) { } catch (e: Exception) {
Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show() Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show()
@ -48,4 +44,4 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : B
} }
dismiss() dismiss()
} }
} }

View File

@ -1,20 +1,21 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.view.LayoutInflater import android.app.Dialog
import androidx.appcompat.app.AlertDialog import android.os.Bundle
import network.loki.messenger.databinding.DialogLinkPreviewBinding import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog import org.thoughtcrime.securesms.createSessionDialog
/** Shown the first time the user inputs a URL that could generate a link preview, to /** Shown the first time the user inputs a URL that could generate a link preview, to
* let them know that Session offers the ability to send and receive link previews. */ * let them know that Session offers the ability to send and receive link previews. */
class LinkPreviewDialog(private val onEnabled: () -> Unit) : BaseDialog() { class LinkPreviewDialog(private val onEnabled: () -> Unit) : DialogFragment() {
override fun setContentView(builder: AlertDialog.Builder) { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val binding = DialogLinkPreviewBinding.inflate(LayoutInflater.from(requireContext())) title(R.string.dialog_link_preview_title)
binding.cancelButton.setOnClickListener { dismiss() } text(R.string.dialog_link_preview_explanation)
binding.enableLinkPreviewsButton.setOnClickListener { enable() } button(R.string.dialog_link_preview_enable_button_title) { enable() }
builder.setView(binding.root) cancelButton { dismiss() }
} }
private fun enable() { private fun enable() {
@ -22,4 +23,4 @@ class LinkPreviewDialog(private val onEnabled: () -> Unit) : BaseDialog() {
dismiss() dismiss()
onEnabled() onEnabled()
} }
} }

View File

@ -1,22 +1,23 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.view.LayoutInflater import android.app.Dialog
import androidx.appcompat.app.AlertDialog import android.os.Bundle
import network.loki.messenger.databinding.DialogSendSeedBinding import androidx.fragment.app.DialogFragment
import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog import network.loki.messenger.R
import org.thoughtcrime.securesms.createSessionDialog
/** Shown if the user is about to send their recovery phrase to someone. */ /** Shown if the user is about to send their recovery phrase to someone. */
class SendSeedDialog(private val proceed: (() -> Unit)? = null) : BaseDialog() { class SendSeedDialog(private val proceed: (() -> Unit)? = null) : DialogFragment() {
override fun setContentView(builder: AlertDialog.Builder) { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val binding = DialogSendSeedBinding.inflate(LayoutInflater.from(requireContext())) title(R.string.dialog_send_seed_title)
binding.cancelButton.setOnClickListener { dismiss() } text(R.string.dialog_send_seed_explanation)
binding.sendSeedButton.setOnClickListener { send() } button(R.string.dialog_send_seed_send_button_title) { send() }
builder.setView(binding.root) cancelButton()
} }
private fun send() { private fun send() {
proceed?.invoke() proceed?.invoke()
dismiss() dismiss()
} }
} }

View File

@ -28,11 +28,10 @@ class MentionCandidateView : RelativeLayout {
private fun update() = with(binding) { private fun update() = with(binding) {
mentionCandidateNameTextView.text = candidate.displayName mentionCandidateNameTextView.text = candidate.displayName
profilePictureView.root.publicKey = candidate.publicKey profilePictureView.publicKey = candidate.publicKey
profilePictureView.root.displayName = candidate.displayName profilePictureView.displayName = candidate.displayName
profilePictureView.root.additionalPublicKey = null profilePictureView.additionalPublicKey = null
profilePictureView.root.glide = glide!! profilePictureView.update()
profilePictureView.root.update()
if (openGroupServer != null && openGroupRoom != null) { if (openGroupServer != null && openGroupRoom != null) {
val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey) val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey)
moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE

View File

@ -67,7 +67,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
menu.findItem(R.id.menu_context_copy_public_key).isVisible = menu.findItem(R.id.menu_context_copy_public_key).isVisible =
(thread.isGroupRecipient && !thread.isOpenGroupRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey) (thread.isGroupRecipient && !thread.isOpenGroupRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
// Message detail // Message detail
menu.findItem(R.id.menu_message_details).isVisible = (selectedItems.size == 1 && firstMessage.isOutgoing) menu.findItem(R.id.menu_message_details).isVisible = selectedItems.size == 1
// Resend // Resend
menu.findItem(R.id.menu_context_resend).isVisible = (selectedItems.size == 1 && firstMessage.isFailed) menu.findItem(R.id.menu_context_resend).isVisible = (selectedItems.size == 1 && firstMessage.isFailed)
// Resync // Resync

View File

@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.conversation.v2.menus
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.PorterDuff import android.graphics.PorterDuff
@ -15,7 +14,6 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.ContextThemeWrapper
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
@ -34,7 +32,6 @@ import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.guava.Optional
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.MediaOverviewActivity import org.thoughtcrime.securesms.MediaOverviewActivity
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.ShortcutLauncherActivity import org.thoughtcrime.securesms.ShortcutLauncherActivity
import org.thoughtcrime.securesms.calls.WebRtcCallActivity import org.thoughtcrime.securesms.calls.WebRtcCallActivity
import org.thoughtcrime.securesms.contacts.SelectContactsActivity import org.thoughtcrime.securesms.contacts.SelectContactsActivity
@ -45,6 +42,8 @@ import org.thoughtcrime.securesms.groups.EditClosedGroupActivity
import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey import org.thoughtcrime.securesms.groups.EditClosedGroupActivity.Companion.groupIDKey
import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity
import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.service.WebRtcCallService
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.showMuteDialog
import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.BitmapUtil
import java.io.IOException import java.io.IOException
@ -64,17 +63,18 @@ object ConversationMenuHelper {
// Base menu (options that should always be present) // Base menu (options that should always be present)
inflater.inflate(R.menu.menu_conversation, menu) inflater.inflate(R.menu.menu_conversation, menu)
// Expiring messages // Expiring messages
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient)) { if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient) && !thread.isBlocked) {
if (thread.expireMessages > 0) { if (thread.expireMessages > 0) {
inflater.inflate(R.menu.menu_conversation_expiration_on, menu) inflater.inflate(R.menu.menu_conversation_expiration_on, menu)
val item = menu.findItem(R.id.menu_expiring_messages) val item = menu.findItem(R.id.menu_expiring_messages)
val actionView = item.actionView item.actionView?.let { actionView ->
val iconView = actionView.findViewById<ImageView>(R.id.menu_badge_icon) val iconView = actionView.findViewById<ImageView>(R.id.menu_badge_icon)
val badgeView = actionView.findViewById<TextView>(R.id.expiration_badge) val badgeView = actionView.findViewById<TextView>(R.id.expiration_badge)
@ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary) @ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary)
iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY) iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY)
badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages) badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages)
actionView.setOnClickListener { onOptionsItemSelected(item) } actionView.setOnClickListener { onOptionsItemSelected(item) }
}
} else { } else {
inflater.inflate(R.menu.menu_conversation_expiration_off, menu) inflater.inflate(R.menu.menu_conversation_expiration_off, menu)
} }
@ -87,7 +87,7 @@ object ConversationMenuHelper {
if (thread.isContactRecipient) { if (thread.isContactRecipient) {
if (thread.isBlocked) { if (thread.isBlocked) {
inflater.inflate(R.menu.menu_conversation_unblock, menu) inflater.inflate(R.menu.menu_conversation_unblock, menu)
} else { } else if (!thread.isLocalNumber) {
inflater.inflate(R.menu.menu_conversation_block, menu) inflater.inflate(R.menu.menu_conversation_block, menu)
} }
} }
@ -186,29 +186,23 @@ object ConversationMenuHelper {
private fun call(context: Context, thread: Recipient) { private fun call(context: Context, thread: Recipient) {
if (!TextSecurePreferences.isCallNotificationsEnabled(context)) { if (!TextSecurePreferences.isCallNotificationsEnabled(context)) {
val dialog = AlertDialog.Builder(context) context.showSessionDialog {
.setTitle(R.string.ConversationActivity_call_title) title(R.string.ConversationActivity_call_title)
.setMessage(R.string.ConversationActivity_call_prompt) text(R.string.ConversationActivity_call_prompt)
.setPositiveButton(R.string.activity_settings_title) { _, _ -> button(R.string.activity_settings_title, R.string.AccessibilityId_settings) {
val intent = Intent(context, PrivacySettingsActivity::class.java) Intent(context, PrivacySettingsActivity::class.java).let(context::startActivity)
context.startActivity(intent)
} }
.setNeutralButton(R.string.cancel) { d, _ -> cancelButton()
d.dismiss() }
}.create()
dialog.getButton(DialogInterface.BUTTON_POSITIVE)?.contentDescription = context.getString(R.string.AccessibilityId_settings)
dialog.getButton(DialogInterface.BUTTON_NEGATIVE)?.contentDescription = context.getString(R.string.AccessibilityId_cancel_button)
dialog.show()
return return
} }
val service = WebRtcCallService.createCall(context, thread) WebRtcCallService.createCall(context, thread)
context.startService(service) .let(context::startService)
val activity = Intent(context, WebRtcCallActivity::class.java).apply { Intent(context, WebRtcCallActivity::class.java)
flags = Intent.FLAG_ACTIVITY_NEW_TASK .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }
} .let(context::startActivity)
context.startActivity(activity)
} }
@ -295,9 +289,7 @@ object ConversationMenuHelper {
private fun leaveClosedGroup(context: Context, thread: Recipient) { private fun leaveClosedGroup(context: Context, thread: Recipient) {
if (!thread.isClosedGroupRecipient) { return } if (!thread.isClosedGroupRecipient) { return }
val builder = AlertDialog.Builder(context)
builder.setTitle(context.resources.getString(R.string.ConversationActivity_leave_group))
builder.setCancelable(true)
val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull() val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
val admins = group.admins val admins = group.admins
val sessionID = TextSecurePreferences.getLocalNumber(context) val sessionID = TextSecurePreferences.getLocalNumber(context)
@ -307,29 +299,25 @@ object ConversationMenuHelper {
} else { } else {
context.resources.getString(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group) context.resources.getString(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group)
} }
builder.setMessage(message)
builder.setPositiveButton(R.string.yes) { _, _ -> fun onLeaveFailed() = Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show()
var groupPublicKey: String?
var isClosedGroup: Boolean context.showSessionDialog {
try { title(R.string.ConversationActivity_leave_group)
groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() text(message)
isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) button(R.string.yes) {
} catch (e: IOException) { try {
groupPublicKey = null val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString()
isClosedGroup = false val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey)
}
try { if (isClosedGroup) MessageSender.leave(groupPublicKey, notifyUser = false)
if (isClosedGroup) { else onLeaveFailed()
MessageSender.leave(groupPublicKey!!, true) } catch (e: Exception) {
} else { onLeaveFailed()
Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show()
} }
} catch (e: Exception) {
Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show()
} }
button(R.string.no)
} }
builder.setNegativeButton(R.string.no, null)
builder.show()
} }
private fun inviteContacts(context: Context, thread: Recipient) { private fun inviteContacts(context: Context, thread: Recipient) {
@ -344,7 +332,7 @@ object ConversationMenuHelper {
} }
private fun mute(context: Context, thread: Recipient) { private fun mute(context: Context, thread: Recipient) {
MuteDialog.show(ContextThemeWrapper(context, context.theme)) { until: Long -> showMuteDialog(ContextThemeWrapper(context, context.theme)) { until ->
DatabaseComponent.get(context).recipientDatabase().setMuted(thread, until) DatabaseComponent.get(context).recipientDatabase().setMuted(thread, until)
} }
} }

View File

@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.text.Spannable import android.text.Spannable
import android.text.style.BackgroundColorSpan import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
@ -15,9 +14,7 @@ import android.view.View
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.ColorUtils
import androidx.core.graphics.BlendModeColorFilterCompat
import androidx.core.graphics.BlendModeCompat
import androidx.core.text.getSpans import androidx.core.text.getSpans
import androidx.core.text.toSpannable import androidx.core.text.toSpannable
import androidx.core.view.children import androidx.core.view.children
@ -28,6 +25,7 @@ import okhttp3.HttpUrl
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
@ -38,15 +36,16 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getInt
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.SmsMessageRecord import org.thoughtcrime.securesms.database.model.SmsMessageRecord
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.GlowViewUtilities
import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.getAccentColor import org.thoughtcrime.securesms.util.getAccentColor
import java.util.* import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
class VisibleMessageContentView : ConstraintLayout { class VisibleMessageContentView : ConstraintLayout {
private val binding: ViewVisibleMessageContentBinding by lazy { ViewVisibleMessageContentBinding.bind(this) } private val binding: ViewVisibleMessageContentBinding by lazy { ViewVisibleMessageContentBinding.bind(this) }
var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf()
var onContentDoubleTap: (() -> Unit)? = null var onContentDoubleTap: (() -> Unit)? = null
var delegate: VisibleMessageViewDelegate? = null var delegate: VisibleMessageViewDelegate? = null
var indexInAdapter: Int = -1 var indexInAdapter: Int = -1
@ -60,21 +59,20 @@ class VisibleMessageContentView : ConstraintLayout {
// region Updating // region Updating
fun bind( fun bind(
message: MessageRecord, message: MessageRecord,
isStartOfMessageCluster: Boolean, isStartOfMessageCluster: Boolean = true,
isEndOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean = true,
glide: GlideRequests, glide: GlideRequests = GlideApp.with(this),
thread: Recipient, thread: Recipient,
searchQuery: String?, searchQuery: String? = null,
contactIsTrusted: Boolean, contactIsTrusted: Boolean = true,
onAttachmentNeedsDownload: (Long, Long) -> Unit onAttachmentNeedsDownload: (Long, Long) -> Unit,
suppressThumbnails: Boolean = false
) { ) {
// Background // Background
val background = getBackground(message.isOutgoing)
val color = if (message.isOutgoing) context.getAccentColor() val color = if (message.isOutgoing) context.getAccentColor()
else context.getColorFromAttr(R.attr.message_received_background_color) else context.getColorFromAttr(R.attr.message_received_background_color)
val filter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN) binding.contentParent.mainColor = color
background.colorFilter = filter binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius)
binding.contentParent.background = background
val onlyBodyMessage = message is SmsMessageRecord val onlyBodyMessage = message is SmsMessageRecord
val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
@ -131,7 +129,6 @@ class VisibleMessageContentView : ConstraintLayout {
delegate?.scrollToMessageIfPossible(quote.id) delegate?.scrollToMessageIfPossible(quote.id)
} }
} }
val hasMedia = message.slideDeck.asAttachments().isNotEmpty()
} }
if (message is MmsMessageRecord) { if (message is MmsMessageRecord) {
@ -188,7 +185,7 @@ class VisibleMessageContentView : ConstraintLayout {
onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) }
} }
} }
message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty() -> { message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> {
/* /*
* Images / Video attachment * Images / Video attachment
*/ */
@ -241,14 +238,15 @@ class VisibleMessageContentView : ConstraintLayout {
binding.contentParent.layoutParams = layoutParams binding.contentParent.layoutParams = layoutParams
} }
private val onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf()
fun onContentClick(event: MotionEvent) {
onContentClick.forEach { clickHandler -> clickHandler.invoke(event) }
}
private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean = private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean =
listOf<View>(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible } listOf<View>(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible }
private fun getBackground(isOutgoing: Boolean): Drawable {
val backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone
return ResourcesCompat.getDrawable(resources, backgroundID, context.theme)!!
}
fun recycle() { fun recycle() {
arrayOf( arrayOf(
binding.deletedMessageView.root, binding.deletedMessageView.root,
@ -266,6 +264,15 @@ class VisibleMessageContentView : ConstraintLayout {
fun playVoiceMessage() { fun playVoiceMessage() {
binding.voiceMessageView.root.togglePlayback() binding.voiceMessageView.root.togglePlayback()
} }
fun playHighlight() {
// Show the highlight colour immediately then slowly fade out
val targetColor = if (ThemeUtil.isDarkTheme(context)) context.getAccentColor() else resources.getColor(R.color.black, context.theme)
val clearTargetColor = ColorUtils.setAlphaComponent(targetColor, 0)
binding.contentParent.numShadowRenders = if (ThemeUtil.isDarkTheme(context)) 3 else 1
binding.contentParent.sessionShadowColor = targetColor
GlowViewUtilities.animateShadowColorChange(binding.contentParent, targetColor, clearTargetColor, 1600)
}
// endregion // endregion
// region Convenience // region Convenience

View File

@ -2,16 +2,17 @@ package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Resources
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Rect import android.graphics.Rect
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
@ -46,6 +47,7 @@ import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.home.UserDetailsBottomSheet import org.thoughtcrime.securesms.home.UserDetailsBottomSheet
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.disableClipping
@ -70,7 +72,6 @@ class VisibleMessageView : LinearLayout {
@Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var mmsDb: MmsDatabase
private val binding by lazy { ViewVisibleMessageBinding.bind(this) } private val binding by lazy { ViewVisibleMessageBinding.bind(this) }
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate() private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate()
private val swipeToReplyIconRect = Rect() private val swipeToReplyIconRect = Rect()
private var dx = 0.0f private var dx = 0.0f
@ -111,7 +112,10 @@ class VisibleMessageView : LinearLayout {
private fun initialize() { private fun initialize() {
isHapticFeedbackEnabled = true isHapticFeedbackEnabled = true
setWillNotDraw(false) setWillNotDraw(false)
binding.root.disableClipping()
binding.mainContainer.disableClipping()
binding.messageInnerContainer.disableClipping() binding.messageInnerContainer.disableClipping()
binding.messageInnerLayout.disableClipping()
binding.messageContentView.root.disableClipping() binding.messageContentView.root.disableClipping()
} }
// endregion // endregion
@ -119,13 +123,14 @@ class VisibleMessageView : LinearLayout {
// region Updating // region Updating
fun bind( fun bind(
message: MessageRecord, message: MessageRecord,
previous: MessageRecord?, previous: MessageRecord? = null,
next: MessageRecord?, next: MessageRecord? = null,
glide: GlideRequests, glide: GlideRequests = GlideApp.with(this),
searchQuery: String?, searchQuery: String? = null,
contact: Contact?, contact: Contact? = null,
senderSessionID: String, senderSessionID: String,
delegate: VisibleMessageViewDelegate?, lastSeen: Long,
delegate: VisibleMessageViewDelegate? = null,
onAttachmentNeedsDownload: (Long, Long) -> Unit onAttachmentNeedsDownload: (Long, Long) -> Unit
) { ) {
val threadID = message.threadId val threadID = message.threadId
@ -136,7 +141,7 @@ class VisibleMessageView : LinearLayout {
// Show profile picture and sender name if this is a group thread AND // Show profile picture and sender name if this is a group thread AND
// the message is incoming // the message is incoming
binding.moderatorIconImageView.isVisible = false binding.moderatorIconImageView.isVisible = false
binding.profilePictureView.root.visibility = when { binding.profilePictureView.visibility = when {
thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE
thread.isGroupRecipient -> View.INVISIBLE thread.isGroupRecipient -> View.INVISIBLE
else -> View.GONE else -> View.GONE
@ -145,25 +150,25 @@ class VisibleMessageView : LinearLayout {
val bottomMargin = if (isEndOfMessageCluster) resources.getDimensionPixelSize(R.dimen.small_spacing) val bottomMargin = if (isEndOfMessageCluster) resources.getDimensionPixelSize(R.dimen.small_spacing)
else ViewUtil.dpToPx(context,2) else ViewUtil.dpToPx(context,2)
if (binding.profilePictureView.root.visibility == View.GONE) { if (binding.profilePictureView.visibility == View.GONE) {
val expirationParams = binding.messageInnerContainer.layoutParams as MarginLayoutParams val expirationParams = binding.messageInnerContainer.layoutParams as MarginLayoutParams
expirationParams.bottomMargin = bottomMargin expirationParams.bottomMargin = bottomMargin
binding.messageInnerContainer.layoutParams = expirationParams binding.messageInnerContainer.layoutParams = expirationParams
} else { } else {
val avatarLayoutParams = binding.profilePictureView.root.layoutParams as MarginLayoutParams val avatarLayoutParams = binding.profilePictureView.layoutParams as MarginLayoutParams
avatarLayoutParams.bottomMargin = bottomMargin avatarLayoutParams.bottomMargin = bottomMargin
binding.profilePictureView.root.layoutParams = avatarLayoutParams binding.profilePictureView.layoutParams = avatarLayoutParams
} }
if (isGroupThread && !message.isOutgoing) { if (isGroupThread && !message.isOutgoing) {
if (isEndOfMessageCluster) { if (isEndOfMessageCluster) {
binding.profilePictureView.root.publicKey = senderSessionID binding.profilePictureView.publicKey = senderSessionID
binding.profilePictureView.root.glide = glide binding.profilePictureView.update(message.individualRecipient)
binding.profilePictureView.root.update(message.individualRecipient) binding.profilePictureView.setOnClickListener {
binding.profilePictureView.root.setOnClickListener {
if (thread.isOpenGroupRecipient) { if (thread.isOpenGroupRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) val openGroup = lokiThreadDb.getOpenGroupChat(threadID)
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) { if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) {
// TODO: support v2 soon
val intent = Intent(context, ConversationActivityV2::class.java) val intent = Intent(context, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID) intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID)
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderSessionID)) intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderSessionID))
@ -177,7 +182,7 @@ class VisibleMessageView : LinearLayout {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return
var standardPublicKey = "" var standardPublicKey = ""
var blindedPublicKey: String? = null var blindedPublicKey: String? = null
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED) { if (IdPrefix.fromValue(senderSessionID)?.isBlinded() == true) {
blindedPublicKey = senderSessionID blindedPublicKey = senderSessionID
} else { } else {
standardPublicKey = senderSessionID standardPublicKey = senderSessionID
@ -191,6 +196,8 @@ class VisibleMessageView : LinearLayout {
val contactContext = val contactContext =
if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID
// Unread marker
binding.unreadMarkerContainer.isVisible = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing
// Date break // Date break
val showDateBreak = isStartOfMessageCluster || snIsSelected val showDateBreak = isStartOfMessageCluster || snIsSelected
binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null
@ -336,11 +343,14 @@ class VisibleMessageView : LinearLayout {
private fun updateExpirationTimer(message: MessageRecord) { private fun updateExpirationTimer(message: MessageRecord) {
val container = binding.messageInnerContainer val container = binding.messageInnerContainer
val content = binding.messageContentView.root val layout = binding.messageInnerLayout
val expiration = binding.expirationTimerView
container.removeAllViewsInLayout() if (message.isOutgoing) binding.messageContentView.root.bringToFront()
container.addView(if (message.isOutgoing) expiration else content) else binding.expirationTimerView.bringToFront()
container.addView(if (message.isOutgoing) content else expiration)
layout.layoutParams = layout.layoutParams.let { it as FrameLayout.LayoutParams }
.apply { gravity = if (message.isOutgoing) Gravity.END else Gravity.START }
val containerParams = container.layoutParams as ConstraintLayout.LayoutParams val containerParams = container.layoutParams as ConstraintLayout.LayoutParams
containerParams.horizontalBias = if (message.isOutgoing) 1f else 0f containerParams.horizontalBias = if (message.isOutgoing) 1f else 0f
container.layoutParams = containerParams container.layoutParams = containerParams
@ -386,7 +396,7 @@ class VisibleMessageView : LinearLayout {
val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing) val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing)
val iconSize = toPx(24, context.resources) val iconSize = toPx(24, context.resources)
val left = binding.messageInnerContainer.left + binding.messageContentView.root.right + spacing val left = binding.messageInnerContainer.left + binding.messageContentView.root.right + spacing
val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.root.marginBottom - (iconSize / 2) val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.marginBottom - (iconSize / 2)
val right = left + iconSize val right = left + iconSize
val bottom = top + iconSize val bottom = top + iconSize
swipeToReplyIconRect.left = left swipeToReplyIconRect.left = left
@ -406,9 +416,13 @@ class VisibleMessageView : LinearLayout {
} }
fun recycle() { fun recycle() {
binding.profilePictureView.root.recycle() binding.profilePictureView.recycle()
binding.messageContentView.root.recycle() binding.messageContentView.root.recycle()
} }
fun playHighlight() {
binding.messageContentView.root.playHighlight()
}
// endregion // endregion
// region Interaction // region Interaction
@ -503,7 +517,7 @@ class VisibleMessageView : LinearLayout {
} }
fun onContentClick(event: MotionEvent) { fun onContentClick(event: MotionEvent) {
binding.messageContentView.root.onContentClick.iterator().forEach { clickHandler -> clickHandler.invoke(event) } binding.messageContentView.root.onContentClick(event)
} }
private fun onPress(event: MotionEvent) { private fun onPress(event: MotionEvent) {

View File

@ -92,7 +92,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener {
if (progress == 1.0) { if (progress == 1.0) {
togglePlayback() togglePlayback()
handleProgressChanged(0.0) handleProgressChanged(0.0)
delegate?.playVoiceMessageAtIndexIfPossible(indexInAdapter - 1) delegate?.playVoiceMessageAtIndexIfPossible(indexInAdapter + 1)
} else { } else {
handleProgressChanged(progress) handleProgressChanged(progress)
} }

View File

@ -25,6 +25,7 @@ import android.content.Intent;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Build;
import android.provider.OpenableColumns; import android.provider.OpenableColumns;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Pair; import android.util.Pair;
@ -244,12 +245,17 @@ public class AttachmentManager {
} }
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) { public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) {
Permissions.with(activity) Permissions.PermissionsBuilder builder = Permissions.with(activity);
.request(Manifest.permission.READ_EXTERNAL_STORAGE) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO)
.withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24) .request(Manifest.permission.READ_MEDIA_IMAGES);
.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode)) } else {
.execute(); builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE);
}
builder.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
.withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24)
.onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode))
.execute();
} }
public static void selectAudio(Activity activity, int requestCode) { public static void selectAudio(Activity activity, int requestCode) {

View File

@ -1,24 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2.utilities
import android.app.Dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import org.thoughtcrime.securesms.util.UiModeUtilities
open class BaseDialog : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = AlertDialog.Builder(requireContext())
setContentView(builder)
val result = builder.create()
result.window?.setDimAmount(0.6f)
return result
}
open fun setContentView(builder: AlertDialog.Builder) {
// To be overridden by subclasses
}
}

View File

@ -1,21 +1,18 @@
package org.thoughtcrime.securesms.conversation.v2.utilities package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context import android.content.Context
import androidx.appcompat.app.AlertDialog
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.showSessionDialog
object NotificationUtils { object NotificationUtils {
fun showNotifyDialog(context: Context, thread: Recipient, notifyTypeHandler: (Int)->Unit) { fun showNotifyDialog(context: Context, thread: Recipient, notifyTypeHandler: (Int)->Unit) {
val notifyTypes = context.resources.getStringArray(R.array.notify_types) context.showSessionDialog {
val currentSelected = thread.notifyType title(R.string.RecipientPreferenceActivity_notification_settings)
singleChoiceItems(
AlertDialog.Builder(context) context.resources.getStringArray(R.array.notify_types),
.setSingleChoiceItems(notifyTypes,currentSelected) { d, newSelection -> thread.notifyType
notifyTypeHandler(newSelection) ) { notifyTypeHandler(it) }
d.dismiss() }
}
.setTitle(R.string.RecipientPreferenceActivity_notification_settings)
.show()
} }
} }

View File

@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.database
import android.content.Context
import androidx.core.content.contentValuesOf
import androidx.core.database.getBlobOrNull
import androidx.core.database.getLongOrNull
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) {
companion object {
private const val VARIANT = "variant"
private const val PUBKEY = "publicKey"
private const val DATA = "data"
private const val TIMESTAMP = "timestamp" // Milliseconds
private const val TABLE_NAME = "configs_table"
const val CREATE_CONFIG_TABLE_COMMAND =
"CREATE TABLE $TABLE_NAME ($VARIANT TEXT NOT NULL, $PUBKEY TEXT NOT NULL, $DATA BLOB, $TIMESTAMP INTEGER NOT NULL DEFAULT 0, PRIMARY KEY($VARIANT, $PUBKEY));"
private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?"
}
fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) {
val db = writableDatabase
val contentValues = contentValuesOf(
VARIANT to variant,
PUBKEY to publicKey,
DATA to data,
TIMESTAMP to timestamp
)
db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey))
}
fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? {
val db = readableDatabase
val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null)
return query?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
val bytes = cursor.getBlobOrNull(cursor.getColumnIndex(DATA)) ?: return@use null
bytes
}
}
fun retrieveConfigLastUpdateTimestamp(variant: String, publicKey: String): Long {
val db = readableDatabase
val cursor = db.query(TABLE_NAME, arrayOf(TIMESTAMP), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null)
if (cursor == null) return 0
if (!cursor.moveToFirst()) return 0
return (cursor.getLongOrNull(cursor.getColumnIndex(TIMESTAMP)) ?: 0)
}
}

View File

@ -36,9 +36,9 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
@SuppressWarnings("unused") @SuppressWarnings("unused")
private static final String TAG = GroupDatabase.class.getSimpleName(); private static final String TAG = GroupDatabase.class.getSimpleName();
static final String TABLE_NAME = "groups"; public static final String TABLE_NAME = "groups";
private static final String ID = "_id"; private static final String ID = "_id";
static final String GROUP_ID = "group_id"; public static final String GROUP_ID = "group_id";
private static final String TITLE = "title"; private static final String TITLE = "title";
private static final String MEMBERS = "members"; private static final String MEMBERS = "members";
private static final String ZOMBIE_MEMBERS = "zombie_members"; private static final String ZOMBIE_MEMBERS = "zombie_members";
@ -133,12 +133,12 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
return new Reader(cursor); return new Reader(cursor);
} }
public List<GroupRecord> getAllGroups() { public List<GroupRecord> getAllGroups(boolean includeInactive) {
Reader reader = getGroups(); Reader reader = getGroups();
GroupRecord record; GroupRecord record;
List<GroupRecord> groups = new LinkedList<>(); List<GroupRecord> groups = new LinkedList<>();
while ((record = reader.getNext()) != null) { while ((record = reader.getNext()) != null) {
if (record.isActive()) { groups.add(record); } if (record.isActive() || includeInactive) { groups.add(record); }
} }
reader.close(); reader.close();
return groups; return groups;

View File

@ -458,9 +458,8 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
return ECKeyPair(DjbECPublicKey(keyPair.publicKey.serialize().removingIdPrefixIfNeeded()), DjbECPrivateKey(keyPair.privateKey.serialize())) return ECKeyPair(DjbECPublicKey(keyPair.publicKey.serialize().removingIdPrefixIfNeeded()), DjbECPrivateKey(keyPair.privateKey.serialize()))
} }
fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) { fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) {
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
val timestamp = Date().time.toString()
val index = "$groupPublicKey-$timestamp" val index = "$groupPublicKey-$timestamp"
val encryptionKeyPairPublicKey = encryptionKeyPair.publicKey.serialize().toHexString().removingIdPrefixIfNeeded() val encryptionKeyPairPublicKey = encryptionKeyPair.publicKey.serialize().toHexString().removingIdPrefixIfNeeded()
val encryptionKeyPairPrivateKey = encryptionKeyPair.privateKey.serialize().toHexString() val encryptionKeyPairPrivateKey = encryptionKeyPair.privateKey.serialize().toHexString()

View File

@ -4,11 +4,8 @@ import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.JsonUtil
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
@ -24,12 +21,6 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
val createPublicChatTableCommand = "CREATE TABLE $publicChatTable ($threadID INTEGER PRIMARY KEY, $publicChat TEXT);" val createPublicChatTableCommand = "CREATE TABLE $publicChatTable ($threadID INTEGER PRIMARY KEY, $publicChat TEXT);"
} }
fun getThreadID(hexEncodedPublicKey: String): Long {
val address = Address.fromSerialized(hexEncodedPublicKey)
val recipient = Recipient.from(context, address, false)
return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
}
fun getAllOpenGroups(): Map<Long, OpenGroup> { fun getAllOpenGroups(): Map<Long, OpenGroup> {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
var cursor: Cursor? = null var cursor: Cursor? = null
@ -61,6 +52,13 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
} }
} }
fun getThreadId(openGroup: OpenGroup): Long? {
val database = databaseHelper.readableDatabase
return database.get(publicChatTable, "$publicChat = ?", arrayOf(JsonUtil.toJson(openGroup.toJson()))) { cursor ->
cursor.getLong(threadID)
}
}
fun setOpenGroupChat(openGroup: OpenGroup, threadID: Long) { fun setOpenGroupChat(openGroup: OpenGroup, threadID: Long) {
if (threadID < 0) { if (threadID < 0) {
return return

View File

@ -20,13 +20,11 @@ import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import com.annimon.stream.Stream import com.annimon.stream.Stream
import com.google.android.mms.pdu_alt.NotificationInd
import com.google.android.mms.pdu_alt.PduHeaders import com.google.android.mms.pdu_alt.PduHeaders
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.signal.IncomingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage
import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage
@ -41,16 +39,13 @@ import org.session.libsession.utilities.Address.Companion.UNKNOWN
import org.session.libsession.utilities.Address.Companion.fromExternal import org.session.libsession.utilities.Address.Companion.fromExternal
import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.Contact import org.session.libsession.utilities.Contact
import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID
import org.session.libsession.utilities.IdentityKeyMismatch import org.session.libsession.utilities.IdentityKeyMismatch
import org.session.libsession.utilities.IdentityKeyMismatchList import org.session.libsession.utilities.IdentityKeyMismatchList
import org.session.libsession.utilities.NetworkFailure import org.session.libsession.utilities.NetworkFailure
import org.session.libsession.utilities.NetworkFailureList import org.session.libsession.utilities.NetworkFailureList
import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled
import org.session.libsession.utilities.Util.toIsoBytes import org.session.libsession.utilities.Util.toIsoBytes
import org.session.libsession.utilities.Util.toIsoString
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.RecipientFormattingException
import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils.queue import org.session.libsignal.utilities.ThreadUtils.queue
@ -162,7 +157,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
) )
get(context).groupReceiptDatabase() get(context).groupReceiptDatabase()
.update(ourAddress, id, status, timestamp) .update(ourAddress, id, status, timestamp)
get(context).threadDatabase().update(threadId, false) get(context).threadDatabase().update(threadId, false, true)
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
} }
} }
@ -205,25 +200,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
} }
@Throws(RecipientFormattingException::class, MmsException::class)
private fun getThreadIdFor(retrieved: IncomingMediaMessage): Long {
return if (retrieved.groupId != null) {
val groupRecipients = Recipient.from(
context,
retrieved.groupId,
true
)
get(context).threadDatabase().getOrCreateThreadIdFor(groupRecipients)
} else {
val sender = Recipient.from(
context,
retrieved.from,
true
)
get(context).threadDatabase().getOrCreateThreadIdFor(sender)
}
}
private fun rawQuery(where: String, arguments: Array<String>?): Cursor { private fun rawQuery(where: String, arguments: Array<String>?): Cursor {
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.rawQuery( return database.rawQuery(
@ -259,7 +235,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
" WHERE " + ID + " = ?", arrayOf(id.toString() + "") " WHERE " + ID + " = ?", arrayOf(id.toString() + "")
) )
if (threadId.isPresent) { if (threadId.isPresent) {
get(context).threadDatabase().update(threadId.get(), false) get(context).threadDatabase().update(threadId.get(), false, true)
} }
} }
@ -316,10 +292,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val attachmentDatabase = get(context).attachmentDatabase() val attachmentDatabase = get(context).attachmentDatabase()
queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) }) queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) })
val threadId = getThreadIdForMessage(messageId) val threadId = getThreadIdForMessage(messageId)
if (!read) {
val mentionChange = if (hasMention) { 1 } else { 0 }
get(context).threadDatabase().decrementUnread(threadId, 1, mentionChange)
}
markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId) markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId)
} }
@ -343,6 +316,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(id.toString())) database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(id.toString()))
} }
fun setMessagesRead(threadId: Long, beforeTime: Long): List<MarkedMessageInfo> {
return setMessagesRead(
THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1) AND " + DATE_SENT + " <= ?",
arrayOf(threadId.toString(), beforeTime.toString())
)
}
fun setMessagesRead(threadId: Long): List<MarkedMessageInfo> { fun setMessagesRead(threadId: Long): List<MarkedMessageInfo> {
return setMessagesRead( return setMessagesRead(
THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)",
@ -567,18 +547,9 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
contentLocation: String, contentLocation: String,
threadId: Long, mailbox: Long, threadId: Long, mailbox: Long,
serverTimestamp: Long, serverTimestamp: Long,
runIncrement: Boolean,
runThreadUpdate: Boolean runThreadUpdate: Boolean
): Optional<InsertResult> { ): Optional<InsertResult> {
var threadId = threadId if (threadId < 0 ) throw MmsException("No thread ID supplied!")
if (threadId == -1L || retrieved.isGroupMessage) {
try {
threadId = getThreadIdFor(retrieved)
} catch (e: RecipientFormattingException) {
Log.w("MmsDatabase", e)
if (threadId == -1L) throw MmsException(e)
}
}
val contentValues = ContentValues() val contentValues = ContentValues()
contentValues.put(DATE_SENT, retrieved.sentTimeMillis) contentValues.put(DATE_SENT, retrieved.sentTimeMillis)
contentValues.put(ADDRESS, retrieved.from.serialize()) contentValues.put(ADDRESS, retrieved.from.serialize())
@ -632,12 +603,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
null, null,
) )
if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) { if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) {
if (runIncrement) {
val mentionAmount = if (retrieved.hasMention()) { 1 } else { 0 }
get(context).threadDatabase().incrementUnread(threadId, 1, mentionAmount)
}
if (runThreadUpdate) { if (runThreadUpdate) {
get(context).threadDatabase().update(threadId, true) get(context).threadDatabase().update(threadId, true, true)
} }
} }
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
@ -651,27 +618,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
serverTimestamp: Long, serverTimestamp: Long,
runThreadUpdate: Boolean runThreadUpdate: Boolean
): Optional<InsertResult> { ): Optional<InsertResult> {
var threadId = threadId if (threadId < 0 ) throw MmsException("No thread ID supplied!")
if (threadId == -1L) {
if (retrieved.isGroup) {
val decodedGroupId: String = if (retrieved is OutgoingExpirationUpdateMessage) {
retrieved.groupId
} else {
(retrieved as OutgoingGroupMediaMessage).groupId
}
val groupId: String
groupId = try {
doubleEncodeGroupID(decodedGroupId)
} catch (e: IOException) {
Log.e(TAG, "Couldn't encrypt group ID")
throw MmsException(e)
}
val group = Recipient.from(context, fromSerialized(groupId), false)
threadId = get(context).threadDatabase().getOrCreateThreadIdFor(group)
} else {
threadId = get(context).threadDatabase().getOrCreateThreadIdFor(retrieved.recipient)
}
}
val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate) val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate)
if (messageId == -1L) { if (messageId == -1L) {
return Optional.absent() return Optional.absent()
@ -686,7 +633,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
retrieved: IncomingMediaMessage, retrieved: IncomingMediaMessage,
threadId: Long, threadId: Long,
serverTimestamp: Long = 0, serverTimestamp: Long = 0,
runIncrement: Boolean,
runThreadUpdate: Boolean runThreadUpdate: Boolean
): Optional<InsertResult> { ): Optional<InsertResult> {
var type = MmsSmsColumns.Types.BASE_INBOX_TYPE or MmsSmsColumns.Types.SECURE_MESSAGE_BIT var type = MmsSmsColumns.Types.BASE_INBOX_TYPE or MmsSmsColumns.Types.SECURE_MESSAGE_BIT
@ -705,7 +651,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
if (retrieved.isMessageRequestResponse) { if (retrieved.isMessageRequestResponse) {
type = type or MmsSmsColumns.Types.MESSAGE_REQUEST_RESPONSE_BIT type = type or MmsSmsColumns.Types.MESSAGE_REQUEST_RESPONSE_BIT
} }
return insertMessageInbox(retrieved, "", threadId, type, serverTimestamp, runIncrement, runThreadUpdate) return insertMessageInbox(retrieved, "", threadId, type, serverTimestamp, runThreadUpdate)
} }
@JvmOverloads @JvmOverloads
@ -794,10 +740,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
) )
} }
with (get(context).threadDatabase()) { with (get(context).threadDatabase()) {
setLastSeen(threadId) val lastSeen = getLastSeenAndHasSent(threadId).first()
if (lastSeen < message.sentTimeMillis) {
setLastSeen(threadId, message.sentTimeMillis)
}
setHasSent(threadId, true) setHasSent(threadId, true)
if (runThreadUpdate) { if (runThreadUpdate) {
update(threadId, true) update(threadId, true, true)
} }
} }
return messageId return messageId
@ -932,7 +881,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
groupReceiptDatabase.deleteRowsForMessage(messageId) groupReceiptDatabase.deleteRowsForMessage(messageId)
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString())) database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString()))
val threadDeleted = get(context).threadDatabase().update(threadId, false) val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
notifyStickerListeners() notifyStickerListeners()
notifyStickerPackListeners() notifyStickerPackListeners()
@ -949,7 +898,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
database!!.delete(TABLE_NAME, ID_IN, arrayOf(messageIds.joinToString(","))) database!!.delete(TABLE_NAME, ID_IN, arrayOf(messageIds.joinToString(",")))
val threadDeleted = get(context).threadDatabase().update(threadId, false) val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
notifyStickerListeners() notifyStickerListeners()
notifyStickerPackListeners() notifyStickerPackListeners()
@ -1147,7 +1096,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
} }
val threadDb = get(context).threadDatabase() val threadDb = get(context).threadDatabase()
for (threadId in threadIds) { for (threadId in threadIds) {
val threadDeleted = threadDb.update(threadId, false) val threadDeleted = threadDb.update(threadId, false, true)
notifyConversationListeners(threadId) notifyConversationListeners(threadId)
} }
notifyStickerListeners() notifyStickerListeners()

View File

@ -16,6 +16,8 @@
*/ */
package org.thoughtcrime.securesms.database; package org.thoughtcrime.securesms.database;
import static org.thoughtcrime.securesms.database.MmsDatabase.MESSAGE_BOX;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
@ -25,6 +27,7 @@ import androidx.annotation.Nullable;
import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteQueryBuilder; import net.zetetic.database.sqlcipher.SQLiteQueryBuilder;
import org.jetbrains.annotations.NotNull;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Util; import org.session.libsession.utilities.Util;
import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId;
@ -36,6 +39,8 @@ import java.io.Closeable;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
import kotlin.Pair;
public class MmsSmsDatabase extends Database { public class MmsSmsDatabase extends Database {
@SuppressWarnings("unused") @SuppressWarnings("unused")
@ -259,8 +264,8 @@ public class MmsSmsDatabase extends Database {
return -1; return -1;
} }
public int getMessagePositionInConversation(long threadId, long sentTimestamp, @NonNull Address address) { public int getMessagePositionInConversation(long threadId, long sentTimestamp, @NonNull Address address, boolean reverse) {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String order = MmsSmsColumns.NORMALIZED_DATE_SENT + (reverse ? " DESC" : " ASC");
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) { try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) {
@ -512,6 +517,23 @@ public class MmsSmsDatabase extends Database {
return new Reader(cursor); return new Reader(cursor);
} }
@NotNull
public Pair<Boolean, Long> timestampAndDirectionForCurrent(@NotNull Cursor cursor) {
int sentColumn = cursor.getColumnIndex(MmsSmsColumns.NORMALIZED_DATE_SENT);
String msgType = cursor.getString(cursor.getColumnIndexOrThrow(TRANSPORT));
long sentTime = cursor.getLong(sentColumn);
long type = 0;
if (MmsSmsDatabase.MMS_TRANSPORT.equals(msgType)) {
int typeIndex = cursor.getColumnIndex(MESSAGE_BOX);
type = cursor.getLong(typeIndex);
} else if (MmsSmsDatabase.SMS_TRANSPORT.equals(msgType)) {
int typeIndex = cursor.getColumnIndex(SmsDatabase.TYPE);
type = cursor.getLong(typeIndex);
}
return new Pair<Boolean, Long>(MmsSmsColumns.Types.isOutgoingMessageType(type), sentTime);
}
public class Reader implements Closeable { public class Reader implements Closeable {
private final Cursor cursor; private final Cursor cursor;

View File

@ -62,13 +62,14 @@ public class RecipientDatabase extends Database {
private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode"; private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode";
private static final String FORCE_SMS_SELECTION = "force_sms_selection"; private static final String FORCE_SMS_SELECTION = "force_sms_selection";
private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none
private static final String WRAPPER_HASH = "wrapper_hash";
private static final String[] RECIPIENT_PROJECTION = new String[] { private static final String[] RECIPIENT_PROJECTION = new String[] {
BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED, BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI, PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
UNIDENTIFIED_ACCESS_MODE, UNIDENTIFIED_ACCESS_MODE,
FORCE_SMS_SELECTION, NOTIFY_TYPE, FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH
}; };
static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
@ -136,6 +137,11 @@ public class RecipientDatabase extends Database {
"OR "+ADDRESS+" IN (SELECT "+GroupDatabase.TABLE_NAME+"."+GroupDatabase.ADMINS+" FROM "+GroupDatabase.TABLE_NAME+")))"; "OR "+ADDRESS+" IN (SELECT "+GroupDatabase.TABLE_NAME+"."+GroupDatabase.ADMINS+" FROM "+GroupDatabase.TABLE_NAME+")))";
} }
public static String getAddWrapperHash() {
return "ALTER TABLE "+TABLE_NAME+" "+
"ADD COLUMN "+WRAPPER_HASH+" TEXT DEFAULT NULL;";
}
public static final int NOTIFY_TYPE_ALL = 0; public static final int NOTIFY_TYPE_ALL = 0;
public static final int NOTIFY_TYPE_MENTIONS = 1; public static final int NOTIFY_TYPE_MENTIONS = 1;
public static final int NOTIFY_TYPE_NONE = 2; public static final int NOTIFY_TYPE_NONE = 2;
@ -154,18 +160,14 @@ public class RecipientDatabase extends Database {
public Optional<RecipientSettings> getRecipientSettings(@NonNull Address address) { public Optional<RecipientSettings> getRecipientSettings(@NonNull Address address) {
SQLiteDatabase database = databaseHelper.getReadableDatabase(); SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = null;
try { try (Cursor cursor = database.query(TABLE_NAME, null, ADDRESS + " = ?", new String[]{address.serialize()}, null, null, null)) {
cursor = database.query(TABLE_NAME, null, ADDRESS + " = ?", new String[] {address.serialize()}, null, null, null);
if (cursor != null && cursor.moveToNext()) { if (cursor != null && cursor.moveToNext()) {
return getRecipientSettings(cursor); return getRecipientSettings(cursor);
} }
return Optional.absent(); return Optional.absent();
} finally {
if (cursor != null) cursor.close();
} }
} }
@ -194,6 +196,7 @@ public class RecipientDatabase extends Database {
String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL)); String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL));
int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE)); int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1; boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1;
String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH));
MaterialColor color; MaterialColor color;
byte[] profileKey = null; byte[] profileKey = null;
@ -225,7 +228,7 @@ public class RecipientDatabase extends Database {
systemPhoneLabel, systemContactUri, systemPhoneLabel, systemContactUri,
signalProfileName, signalProfileAvatar, profileSharing, signalProfileName, signalProfileAvatar, profileSharing,
notificationChannel, Recipient.UnidentifiedAccessMode.fromMode(unidentifiedAccessMode), notificationChannel, Recipient.UnidentifiedAccessMode.fromMode(unidentifiedAccessMode),
forceSmsSelection)); forceSmsSelection, wrapperHash));
} }
public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) { public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) {
@ -252,6 +255,24 @@ public class RecipientDatabase extends Database {
notifyRecipientListeners(); notifyRecipientListeners();
} }
public boolean getApproved(@NonNull Address address) {
SQLiteDatabase db = getReadableDatabase();
try (Cursor cursor = db.query(TABLE_NAME, new String[]{APPROVED}, ADDRESS + " = ?", new String[]{address.serialize()}, null, null, null)) {
if (cursor != null && cursor.moveToNext()) {
return cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1;
}
}
return false;
}
public void setRecipientHash(@NonNull Recipient recipient, String recipientHash) {
ContentValues values = new ContentValues();
values.put(WRAPPER_HASH, recipientHash);
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setWrapperHash(recipientHash);
notifyRecipientListeners();
}
public void setApproved(@NonNull Recipient recipient, boolean approved) { public void setApproved(@NonNull Recipient recipient, boolean approved) {
ContentValues values = new ContentValues(); ContentValues values = new ContentValues();
values.put(APPROVED, approved ? 1 : 0); values.put(APPROVED, approved ? 1 : 0);
@ -268,14 +289,6 @@ public class RecipientDatabase extends Database {
notifyRecipientListeners(); notifyRecipientListeners();
} }
public void setBlocked(@NonNull Recipient recipient, boolean blocked) {
ContentValues values = new ContentValues();
values.put(BLOCK, blocked ? 1 : 0);
updateOrInsert(recipient.getAddress(), values);
recipient.resolve().setBlocked(blocked);
notifyRecipientListeners();
}
public void setBlocked(@NonNull Iterable<Recipient> recipients, boolean blocked) { public void setBlocked(@NonNull Iterable<Recipient> recipients, boolean blocked) {
SQLiteDatabase db = getWritableDatabase(); SQLiteDatabase db = getWritableDatabase();
db.beginTransaction(); db.beginTransaction();

View File

@ -2,10 +2,12 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import androidx.core.database.getStringOrNull
import android.database.Cursor import android.database.Cursor
import androidx.core.database.getStringOrNull
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) {
@ -43,6 +45,9 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
val database = databaseHelper.readableDatabase val database = databaseHelper.readableDatabase
return database.getAll(sessionContactTable, null, null) { cursor -> return database.getAll(sessionContactTable, null, null) { cursor ->
contactFromCursor(cursor) contactFromCursor(cursor)
}.filter { contact ->
val sessionId = SessionId(contact.sessionID)
sessionId.prefix == IdPrefix.STANDARD
}.toSet() }.toSet()
} }

View File

@ -93,6 +93,7 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
fun cancelPendingMessageSendJobs(threadID: Long) { fun cancelPendingMessageSendJobs(threadID: Long) {
val database = databaseHelper.writableDatabase val database = databaseHelper.writableDatabase
val attachmentUploadJobKeys = mutableListOf<String>() val attachmentUploadJobKeys = mutableListOf<String>()
database.beginTransaction()
database.getAll(sessionJobTable, "$jobType = ?", arrayOf( AttachmentUploadJob.KEY )) { cursor -> database.getAll(sessionJobTable, "$jobType = ?", arrayOf( AttachmentUploadJob.KEY )) { cursor ->
val job = jobFromCursor(cursor) as AttachmentUploadJob? val job = jobFromCursor(cursor) as AttachmentUploadJob?
if (job != null && job.threadID == threadID.toString()) { attachmentUploadJobKeys.add(job.id!!) } if (job != null && job.threadID == threadID.toString()) { attachmentUploadJobKeys.add(job.id!!) }
@ -103,15 +104,19 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa
if (job != null && job.message.threadID == threadID) { messageSendJobKeys.add(job.id!!) } if (job != null && job.message.threadID == threadID) { messageSendJobKeys.add(job.id!!) }
} }
if (attachmentUploadJobKeys.isNotEmpty()) { if (attachmentUploadJobKeys.isNotEmpty()) {
val attachmentUploadJobKeysAsString = attachmentUploadJobKeys.joinToString(", ") attachmentUploadJobKeys.forEach {
database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} IN (?)", database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} = ?",
arrayOf( AttachmentUploadJob.KEY, attachmentUploadJobKeysAsString )) arrayOf( AttachmentUploadJob.KEY, it ))
}
} }
if (messageSendJobKeys.isNotEmpty()) { if (messageSendJobKeys.isNotEmpty()) {
val messageSendJobKeysAsString = messageSendJobKeys.joinToString(", ") messageSendJobKeys.forEach {
database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} IN (?)", database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} = ?",
arrayOf( MessageSendJob.KEY, messageSendJobKeysAsString )) arrayOf( MessageSendJob.KEY, it ))
}
} }
database.setTransactionSuccessful()
database.endTransaction()
} }
fun isJobCanceled(job: Job): Boolean { fun isJobCanceled(job: Job): Boolean {

View File

@ -148,7 +148,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(id); long threadId = getThreadIdForMessage(id);
DatabaseComponent.get(context).threadDatabase().update(threadId, false); DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
} }
@ -234,10 +234,6 @@ public class SmsDatabase extends MessagingDatabase {
contentValues.put(BODY, ""); contentValues.put(BODY, "");
contentValues.put(HAS_MENTION, 0); contentValues.put(HAS_MENTION, 0);
database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)});
long threadId = getThreadIdForMessage(messageId);
if (!read) {
DatabaseComponent.get(context).threadDatabase().decrementUnread(threadId, 1, (hasMention ? 1 : 0));
}
updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE); updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE);
} }
@ -256,7 +252,7 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(id); long threadId = getThreadIdForMessage(id);
DatabaseComponent.get(context).threadDatabase().update(threadId, false); DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
} }
@ -319,7 +315,7 @@ public class SmsDatabase extends MessagingDatabase {
ID + " = ?", ID + " = ?",
new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))}); new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))});
DatabaseComponent.get(context).threadDatabase().update(threadId, false); DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
foundMessage = true; foundMessage = true;
} }
@ -337,6 +333,9 @@ public class SmsDatabase extends MessagingDatabase {
} }
} }
public List<MarkedMessageInfo> setMessagesRead(long threadId, long beforeTime) {
return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1) AND " + DATE_SENT + " <= ?", new String[]{threadId+"", beforeTime+""});
}
public List<MarkedMessageInfo> setMessagesRead(long threadId) { public List<MarkedMessageInfo> setMessagesRead(long threadId) {
return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", new String[] {String.valueOf(threadId)}); return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", new String[] {String.valueOf(threadId)});
} }
@ -400,14 +399,14 @@ public class SmsDatabase extends MessagingDatabase {
long threadId = getThreadIdForMessage(messageId); long threadId = getThreadIdForMessage(messageId);
DatabaseComponent.get(context).threadDatabase().update(threadId, true); DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
notifyConversationListListeners(); notifyConversationListListeners();
return new Pair<>(messageId, threadId); return new Pair<>(messageId, threadId);
} }
protected Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) { protected Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runThreadUpdate) {
if (message.isSecureMessage()) { if (message.isSecureMessage()) {
type |= Types.SECURE_MESSAGE_BIT; type |= Types.SECURE_MESSAGE_BIT;
} else if (message.isGroup()) { } else if (message.isGroup()) {
@ -486,12 +485,8 @@ public class SmsDatabase extends MessagingDatabase {
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
long messageId = db.insert(TABLE_NAME, null, values); long messageId = db.insert(TABLE_NAME, null, values);
if (unread && runIncrement) {
DatabaseComponent.get(context).threadDatabase().incrementUnread(threadId, 1, (message.hasMention() ? 1 : 0));
}
if (runThreadUpdate) { if (runThreadUpdate) {
DatabaseComponent.get(context).threadDatabase().update(threadId, true); DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
} }
if (message.getSubscriptionId() != -1) { if (message.getSubscriptionId() != -1) {
@ -504,16 +499,16 @@ public class SmsDatabase extends MessagingDatabase {
} }
} }
public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, boolean runIncrement, boolean runThreadUpdate) { public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, boolean runThreadUpdate) {
return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runIncrement, runThreadUpdate); return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runThreadUpdate);
} }
public Optional<InsertResult> insertCallMessage(IncomingTextMessage message) { public Optional<InsertResult> insertCallMessage(IncomingTextMessage message) {
return insertMessageInbox(message, 0, 0, true, true); return insertMessageInbox(message, 0, 0, true);
} }
public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) { public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runThreadUpdate) {
return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runIncrement, runThreadUpdate); return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runThreadUpdate);
} }
public Optional<InsertResult> insertMessageOutbox(long threadId, OutgoingTextMessage message, long serverTimestamp, boolean runThreadUpdate) { public Optional<InsertResult> insertMessageOutbox(long threadId, OutgoingTextMessage message, long serverTimestamp, boolean runThreadUpdate) {
@ -567,9 +562,12 @@ public class SmsDatabase extends MessagingDatabase {
} }
if (runThreadUpdate) { if (runThreadUpdate) {
DatabaseComponent.get(context).threadDatabase().update(threadId, true); DatabaseComponent.get(context).threadDatabase().update(threadId, true, true);
}
long lastSeen = DatabaseComponent.get(context).threadDatabase().getLastSeenAndHasSent(threadId).first();
if (lastSeen < message.getSentTimestampMillis()) {
DatabaseComponent.get(context).threadDatabase().setLastSeen(threadId, message.getSentTimestampMillis());
} }
DatabaseComponent.get(context).threadDatabase().setLastSeen(threadId);
DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true); DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true);
@ -616,7 +614,7 @@ public class SmsDatabase extends MessagingDatabase {
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
long threadId = getThreadIdForMessage(messageId); long threadId = getThreadIdForMessage(messageId);
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false); boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
return threadDeleted; return threadDeleted;
} }
@ -640,7 +638,7 @@ public class SmsDatabase extends MessagingDatabase {
ID + " IN (" + StringUtils.join(argsArray, ',') + ")", ID + " IN (" + StringUtils.join(argsArray, ',') + ")",
argValues argValues
); );
boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false); boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
return threadDeleted; return threadDeleted;
} }

View File

@ -2,16 +2,43 @@ package org.thoughtcrime.securesms.database
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED
import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile
import network.loki.messenger.libsession_util.util.BaseCommunityInfo
import network.loki.messenger.libsession_util.util.Conversation
import network.loki.messenger.libsession_util.util.ExpiryMode
import network.loki.messenger.libsession_util.util.GroupInfo
import network.loki.messenger.libsession_util.util.UserPic
import org.session.libsession.avatars.AvatarHelper import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.BlindedIdMapping import org.session.libsession.messaging.BlindedIdMapping
import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.calls.CallMessageType
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.jobs.* import org.session.libsession.messaging.jobs.AttachmentUploadJob
import org.session.libsession.messaging.jobs.BackgroundGroupAddJob
import org.session.libsession.messaging.jobs.ConfigurationSyncJob
import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob
import org.session.libsession.messaging.jobs.Job
import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.jobs.MessageReceiveJob
import org.session.libsession.messaging.jobs.MessageSendJob
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
import org.session.libsession.messaging.messages.Destination
import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.Message
import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.MessageRequestResponse
import org.session.libsession.messaging.messages.signal.* import org.session.libsession.messaging.messages.signal.IncomingEncryptedMessage
import org.session.libsession.messaging.messages.signal.IncomingGroupMessage
import org.session.libsession.messaging.messages.signal.IncomingMediaMessage
import org.session.libsession.messaging.messages.signal.IncomingTextMessage
import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage
import org.session.libsession.messaging.messages.signal.OutgoingTextMessage
import org.session.libsession.messaging.messages.visible.Attachment import org.session.libsession.messaging.messages.visible.Attachment
import org.session.libsession.messaging.messages.visible.Profile import org.session.libsession.messaging.messages.visible.Profile
import org.session.libsession.messaging.messages.visible.Reaction import org.session.libsession.messaging.messages.visible.Reaction
@ -23,12 +50,15 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.utilities.* import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
@ -36,24 +66,104 @@ import org.session.libsession.utilities.ProfileKeyUtil
import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.crypto.ecc.DjbECPrivateKey
import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.crypto.ecc.ECKeyPair
import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceAttachmentPointer
import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.messages.SignalServiceGroup
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.guava.Optional
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.ClosedGroupManager
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.SessionMetaProtocol import org.thoughtcrime.securesms.util.SessionMetaProtocol
import java.security.MessageDigest import java.security.MessageDigest
import network.loki.messenger.libsession_util.util.Contact as LibSessionContact
open class Storage(context: Context, helper: SQLCipherOpenHelper, private val configFactory: ConfigFactory) : Database(context, helper), StorageProtocol,
ThreadDatabase.ConversationThreadUpdateListener {
override fun threadCreated(address: Address, threadId: Long) {
val localUserAddress = getUserPublicKey() ?: return
if (!getRecipientApproved(address) && localUserAddress != address.serialize()) return // don't store unapproved / message requests
val volatile = configFactory.convoVolatile ?: return
if (address.isGroup) {
val groups = configFactory.userGroups ?: return
if (address.isClosedGroup) {
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
val closedGroup = getGroup(address.toGroupString())
if (closedGroup != null && closedGroup.isActive) {
val legacyGroup = groups.getOrConstructLegacyGroupInfo(sessionId)
groups.set(legacyGroup)
val newVolatileParams = volatile.getOrConstructLegacyGroup(sessionId).copy(
lastRead = SnodeAPI.nowWithOffset,
)
volatile.set(newVolatileParams)
}
} else if (address.isOpenGroup) {
// these should be added on the group join / group info fetch
Log.w("Loki", "Thread created called for open group address, not adding any extra information")
}
} else if (address.isContact) {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return
// don't update our own address into the contacts DB
if (getUserPublicKey() != address.serialize()) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(address.serialize()) {
priority = ConfigBase.PRIORITY_VISIBLE
}
} else {
val userProfile = configFactory.user ?: return
userProfile.setNtsPriority(ConfigBase.PRIORITY_VISIBLE)
DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true)
}
val newVolatileParams = volatile.getOrConstructOneToOne(address.serialize())
volatile.set(newVolatileParams)
}
}
override fun threadDeleted(address: Address, threadId: Long) {
val volatile = configFactory.convoVolatile ?: return
if (address.isGroup) {
val groups = configFactory.userGroups ?: return
if (address.isClosedGroup) {
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
volatile.eraseLegacyClosedGroup(sessionId)
groups.eraseLegacyGroup(sessionId)
} else if (address.isOpenGroup) {
// these should be removed in the group leave / handling new configs
Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere")
}
} else {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return
volatile.eraseOneToOne(address.serialize())
if (getUserPublicKey() != address.serialize()) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(address.serialize()) {
priority = PRIORITY_HIDDEN
}
} else {
val userProfile = configFactory.user ?: return
userProfile.setNtsPriority(PRIORITY_HIDDEN)
}
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), StorageProtocol {
override fun getUserPublicKey(): String? { override fun getUserPublicKey(): String? {
return TextSecurePreferences.getLocalNumber(context) return TextSecurePreferences.getLocalNumber(context)
} }
@ -74,6 +184,25 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
database.setProfileAvatar(recipient, profileAvatar) database.setProfileAvatar(recipient, profileAvatar)
} }
override fun setProfilePicture(recipient: Recipient, newProfilePicture: String?, newProfileKey: ByteArray?) {
val db = DatabaseComponent.get(context).recipientDatabase()
db.setProfileAvatar(recipient, newProfilePicture)
db.setProfileKey(recipient, newProfileKey)
}
override fun setUserProfilePicture(newProfilePicture: String?, newProfileKey: ByteArray?) {
val ourRecipient = fromSerialized(getUserPublicKey()!!).let {
Recipient.from(context, it, false)
}
ourRecipient.resolve().profileKey = newProfileKey
TextSecurePreferences.setProfileKey(context, newProfileKey?.let { Base64.encodeBytes(it) })
TextSecurePreferences.setProfilePictureURL(context, newProfilePicture)
if (newProfileKey != null) {
JobQueue.shared.add(RetrieveProfileAvatarJob(newProfilePicture, ourRecipient.address))
}
}
override fun getOrGenerateRegistrationID(): Int { override fun getOrGenerateRegistrationID(): Int {
var registrationID = TextSecurePreferences.getLocalRegistrationId(context) var registrationID = TextSecurePreferences.getLocalRegistrationId(context)
if (registrationID == 0) { if (registrationID == 0) {
@ -94,19 +223,56 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return database.getAttachmentsForMessage(messageID) return database.getAttachmentsForMessage(messageID)
} }
override fun markConversationAsRead(threadId: Long, updateLastSeen: Boolean) { override fun getLastSeen(threadId: Long): Long {
val threadDb = DatabaseComponent.get(context).threadDatabase() val threadDb = DatabaseComponent.get(context).threadDatabase()
threadDb.setRead(threadId, updateLastSeen) return threadDb.getLastSeenAndHasSent(threadId)?.first() ?: 0L
} }
override fun incrementUnread(threadId: Long, amount: Int, unreadMentionAmount: Int) { override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean) {
val threadDb = DatabaseComponent.get(context).threadDatabase() val threadDb = DatabaseComponent.get(context).threadDatabase()
threadDb.incrementUnread(threadId, amount, unreadMentionAmount) getRecipientForThread(threadId)?.let { recipient ->
val currentLastRead = threadDb.getLastSeenAndHasSent(threadId).first()
// don't set the last read in the volatile if we didn't set it in the DB
if (!threadDb.markAllAsRead(threadId, recipient.isGroupRecipient, lastSeenTime, force) && !force) return
// don't process configs for inbox recipients
if (recipient.isOpenGroupInboxRecipient) return
configFactory.convoVolatile?.let { config ->
val convo = when {
// recipient closed group
recipient.isClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize()))
// recipient is open group
recipient.isOpenGroupRecipient -> {
val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return
BaseCommunityInfo.parseFullUrl(openGroupJoinUrl)?.let { (base, room, pubKey) ->
config.getOrConstructCommunity(base, room, pubKey)
} ?: return
}
// otherwise recipient is one to one
recipient.isContactRecipient -> {
// don't process non-standard session IDs though
val sessionId = SessionId(recipient.address.serialize())
if (sessionId.prefix != IdPrefix.STANDARD) return
config.getOrConstructOneToOne(recipient.address.serialize())
}
else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address.serialize()}")
}
convo.lastRead = lastSeenTime
if (convo.unread) {
convo.unread = lastSeenTime <= currentLastRead
notifyConversationListListeners()
}
config.set(convo)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
}
} }
override fun updateThread(threadId: Long, unarchive: Boolean) { override fun updateThread(threadId: Long, unarchive: Boolean) {
val threadDb = DatabaseComponent.get(context).threadDatabase() val threadDb = DatabaseComponent.get(context).threadDatabase()
threadDb.update(threadId, unarchive) threadDb.update(threadId, unarchive, false)
} }
override fun persist(message: VisibleMessage, override fun persist(message: VisibleMessage,
@ -115,7 +281,6 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
groupPublicKey: String?, groupPublicKey: String?,
openGroupID: String?, openGroupID: String?,
attachments: List<Attachment>, attachments: List<Attachment>,
runIncrement: Boolean,
runThreadUpdate: Boolean): Long? { runThreadUpdate: Boolean): Long? {
var messageID: Long? = null var messageID: Long? = null
val senderAddress = fromSerialized(message.sender!!) val senderAddress = fromSerialized(message.sender!!)
@ -142,13 +307,16 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
} }
val targetRecipient = Recipient.from(context, targetAddress, false) val targetRecipient = Recipient.from(context, targetAddress, false)
if (!targetRecipient.isGroupRecipient) { if (!targetRecipient.isGroupRecipient) {
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
if (isUserSender || isUserBlindedSender) { if (isUserSender || isUserBlindedSender) {
recipientDb.setApproved(targetRecipient, true) setRecipientApproved(targetRecipient, true)
} else { } else {
recipientDb.setApprovedMe(targetRecipient, true) setRecipientApprovedMe(targetRecipient, true)
} }
} }
if (message.threadID == null && !targetRecipient.isOpenGroupRecipient) {
// open group recipients should explicitly create threads
message.threadID = getOrCreateThreadIdFor(targetAddress)
}
if (message.isMediaMessage() || attachments.isNotEmpty()) { if (message.isMediaMessage() || attachments.isNotEmpty()) {
val quote: Optional<QuoteModel> = if (quotes != null) Optional.of(quotes) else Optional.absent() val quote: Optional<QuoteModel> = if (quotes != null) Optional.of(quotes) else Optional.absent()
val linkPreviews: Optional<List<LinkPreview>> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! }) val linkPreviews: Optional<List<LinkPreview>> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! })
@ -162,7 +330,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
it.toSignalPointer() it.toSignalPointer()
} }
val mediaMessage = IncomingMediaMessage.from(message, senderAddress, targetRecipient.expireMessages * 1000L, group, signalServiceAttachments, quote, linkPreviews) val mediaMessage = IncomingMediaMessage.from(message, senderAddress, targetRecipient.expireMessages * 1000L, group, signalServiceAttachments, quote, linkPreviews)
mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID ?: -1, message.receivedTimestamp ?: 0, runIncrement, runThreadUpdate) mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID!!, message.receivedTimestamp ?: 0, runThreadUpdate)
} }
if (insertResult.isPresent) { if (insertResult.isPresent) {
messageID = insertResult.get().messageId messageID = insertResult.get().messageId
@ -179,7 +347,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp) val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp)
else IncomingTextMessage.from(message, senderAddress, group, targetRecipient.expireMessages * 1000L) else IncomingTextMessage.from(message, senderAddress, group, targetRecipient.expireMessages * 1000L)
val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody) val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody)
smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runIncrement, runThreadUpdate) smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runThreadUpdate)
} }
insertResult.orNull()?.let { result -> insertResult.orNull()?.let { result ->
messageID = result.messageId messageID = result.messageId
@ -225,6 +393,12 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return DatabaseComponent.get(context).sessionJobDatabase().getGroupAvatarDownloadJob(server, room, imageId) return DatabaseComponent.get(context).sessionJobDatabase().getGroupAvatarDownloadJob(server, room, imageId)
} }
override fun getConfigSyncJob(destination: Destination): Job? {
return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(ConfigurationSyncJob.KEY).values.firstOrNull {
(it as? ConfigurationSyncJob)?.destination == destination
}
}
override fun resumeMessageSendJobIfNeeded(messageSendJobID: String) { override fun resumeMessageSendJobIfNeeded(messageSendJobID: String) {
val job = DatabaseComponent.get(context).sessionJobDatabase().getMessageSendJob(messageSendJobID) ?: return val job = DatabaseComponent.get(context).sessionJobDatabase().getMessageSendJob(messageSendJobID) ?: return
JobQueue.shared.resumePendingSendMessage(job) JobQueue.shared.resumePendingSendMessage(job)
@ -234,11 +408,201 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return DatabaseComponent.get(context).sessionJobDatabase().isJobCanceled(job) return DatabaseComponent.get(context).sessionJobDatabase().isJobCanceled(job)
} }
override fun cancelPendingMessageSendJobs(threadID: Long) {
val jobDb = DatabaseComponent.get(context).sessionJobDatabase()
jobDb.cancelPendingMessageSendJobs(threadID)
}
override fun getAuthToken(room: String, server: String): String? { override fun getAuthToken(room: String, server: String): String? {
val id = "$server.$room" val id = "$server.$room"
return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id) return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id)
} }
override fun notifyConfigUpdates(forConfigObject: ConfigBase) {
notifyUpdates(forConfigObject)
}
override fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean {
return configFactory.conversationInConfig(publicKey, groupPublicKey, openGroupId, visibleOnly)
}
override fun canPerformConfigChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean {
return configFactory.canPerformChange(variant, publicKey, changeTimestampMs)
}
fun notifyUpdates(forConfigObject: ConfigBase) {
when (forConfigObject) {
is UserProfile -> updateUser(forConfigObject)
is Contacts -> updateContacts(forConfigObject)
is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject)
is UserGroupsConfig -> updateUserGroups(forConfigObject)
}
}
private fun updateUser(userProfile: UserProfile) {
val userPublicKey = getUserPublicKey() ?: return
// would love to get rid of recipient and context from this
val recipient = Recipient.from(context, fromSerialized(userPublicKey), false)
// update name
val name = userProfile.getName() ?: return
val userPic = userProfile.getPic()
val profileManager = SSKEnvironment.shared.profileManager
if (name.isNotEmpty()) {
TextSecurePreferences.setProfileName(context, name)
profileManager.setName(context, recipient, name)
}
// update pfp
if (userPic == UserPic.DEFAULT) {
clearUserPic()
} else if (userPic.key.isNotEmpty() && userPic.url.isNotEmpty()
&& TextSecurePreferences.getProfilePictureURL(context) != userPic.url) {
setUserProfilePicture(userPic.url, userPic.key)
}
if (userProfile.getNtsPriority() == PRIORITY_HIDDEN) {
// delete nts thread if needed
val ourThread = getThreadId(recipient) ?: return
deleteConversation(ourThread)
} else {
// create note to self thread if needed (?)
val ourThread = getOrCreateThreadIdFor(recipient.address)
DatabaseComponent.get(context).threadDatabase().setHasSent(ourThread, true)
setPinned(ourThread, userProfile.getNtsPriority() > 0)
}
}
private fun updateContacts(contacts: Contacts) {
val extracted = contacts.all().toList()
addLibSessionContacts(extracted)
}
override fun clearUserPic() {
val userPublicKey = getUserPublicKey() ?: return
val recipientDatabase = DatabaseComponent.get(context).recipientDatabase()
// would love to get rid of recipient and context from this
val recipient = Recipient.from(context, fromSerialized(userPublicKey), false)
// clear picture if userPic is null
TextSecurePreferences.setProfileKey(context, null)
ProfileKeyUtil.setEncodedProfileKey(context, null)
recipientDatabase.setProfileAvatar(recipient, null)
TextSecurePreferences.setProfileAvatarId(context, 0)
TextSecurePreferences.setProfilePictureURL(context, null)
Recipient.removeCached(fromSerialized(userPublicKey))
configFactory.user?.setPic(UserPic.DEFAULT)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
private fun updateConvoVolatile(convos: ConversationVolatileConfig) {
val extracted = convos.all()
for (conversation in extracted) {
val threadId = when (conversation) {
is Conversation.OneToOne -> getThreadIdFor(conversation.sessionId, null, null, createThread = false)
is Conversation.LegacyGroup -> getThreadIdFor("", conversation.groupId,null, createThread = false)
is Conversation.Community -> getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false)
}
if (threadId != null) {
if (conversation.lastRead > getLastSeen(threadId)) {
markConversationAsRead(threadId, conversation.lastRead, force = true)
}
updateThread(threadId, false)
}
}
}
private fun updateUserGroups(userGroups: UserGroupsConfig) {
val threadDb = DatabaseComponent.get(context).threadDatabase()
val localUserPublicKey = getUserPublicKey() ?: return Log.w(
"Loki",
"No user public key when trying to update user groups from config"
)
val communities = userGroups.allCommunityInfo()
val lgc = userGroups.allLegacyGroupInfo()
val allOpenGroups = getAllOpenGroups()
val toDeleteCommunities = allOpenGroups.filter {
Conversation.Community(BaseCommunityInfo(it.value.server, it.value.room, it.value.publicKey), 0, false).baseCommunityInfo.fullUrl() !in communities.map { it.community.fullUrl() }
}
val existingCommunities: Map<Long, OpenGroup> = allOpenGroups.filterKeys { it !in toDeleteCommunities.keys }
val toAddCommunities = communities.filter { it.community.fullUrl() !in existingCommunities.map { it.value.joinURL } }
val existingJoinUrls = existingCommunities.values.map { it.joinURL }
val existingClosedGroups = getAllGroups(includeInactive = true).filter { it.isClosedGroup }
val lgcIds = lgc.map { it.sessionId }
val toDeleteClosedGroups = existingClosedGroups.filter { group ->
GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds
}
// delete the ones which are not listed in the config
toDeleteCommunities.values.forEach { openGroup ->
OpenGroupManager.delete(openGroup.server, openGroup.room, context)
}
toDeleteClosedGroups.forEach { deleteGroup ->
val threadId = getThreadId(deleteGroup.encodedId)
if (threadId != null) {
ClosedGroupManager.silentlyRemoveGroup(context,threadId,GroupUtil.doubleDecodeGroupId(deleteGroup.encodedId), deleteGroup.encodedId, localUserPublicKey, delete = true)
}
}
toAddCommunities.forEach { toAddCommunity ->
val joinUrl = toAddCommunity.community.fullUrl()
if (!hasBackgroundGroupAddJob(joinUrl)) {
JobQueue.shared.add(BackgroundGroupAddJob(joinUrl))
}
}
for (groupInfo in communities) {
val groupBaseCommunity = groupInfo.community
if (groupBaseCommunity.fullUrl() in existingJoinUrls) {
// add it
val (threadId, _) = existingCommunities.entries.first { (_, v) -> v.joinURL == groupInfo.community.fullUrl() }
threadDb.setPinned(threadId, groupInfo.priority == PRIORITY_PINNED)
}
}
for (group in lgc) {
val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId }
val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) }
if (existingGroup != null) {
if (group.priority == PRIORITY_HIDDEN && existingThread != null) {
ClosedGroupManager.silentlyRemoveGroup(context,existingThread,GroupUtil.doubleDecodeGroupId(existingGroup.encodedId), existingGroup.encodedId, localUserPublicKey, delete = true)
} else if (existingThread == null) {
Log.w("Loki-DBG", "Existing group had no thread to hide")
} else {
Log.d("Loki-DBG", "Setting existing group pinned status to ${group.priority}")
threadDb.setPinned(existingThread, group.priority == PRIORITY_PINNED)
}
} else {
val members = group.members.keys.map { Address.fromSerialized(it) }
val admins = group.members.filter { it.value /*admin = true*/ }.keys.map { Address.fromSerialized(it) }
val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId)
val title = group.name
val formationTimestamp = (group.joinedAt * 1000L)
createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp)
setProfileSharing(Address.fromSerialized(groupId), true)
// Add the group to the user's set of public keys to poll for
addClosedGroupPublicKey(group.sessionId)
// Store the encryption key pair
val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey))
addClosedGroupEncryptionKeyPair(keyPair, group.sessionId, SnodeAPI.nowWithOffset)
// Set expiration timer
val expireTimer = group.disappearingTimer
setExpirationTimer(groupId, expireTimer.toInt())
// Notify the PN server
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, group.sessionId, localUserPublicKey)
// Notify the user
val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId))
threadDb.setDate(threadID, formationTimestamp)
insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp)
// Don't create config group here, it's from a config update
// Start polling
ClosedGroupPollerV2.shared.startPolling(group.sessionId)
}
}
}
override fun setAuthToken(room: String, server: String, newValue: String) { override fun setAuthToken(room: String, server: String, newValue: String) {
val id = "$server.$room" val id = "$server.$room"
DatabaseComponent.get(context).lokiAPIDatabase().setAuthToken(id, newValue) DatabaseComponent.get(context).lokiAPIDatabase().setAuthToken(id, newValue)
@ -474,6 +838,59 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp) DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp)
} }
override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) {
val volatiles = configFactory.convoVolatile ?: return
val userGroups = configFactory.userGroups ?: return
val groupVolatileConfig = volatiles.getOrConstructLegacyGroup(groupPublicKey)
groupVolatileConfig.lastRead = formationTimestamp
volatiles.set(groupVolatileConfig)
val groupInfo = GroupInfo.LegacyGroupInfo(
sessionId = groupPublicKey,
name = name,
members = members,
priority = ConfigBase.PRIORITY_VISIBLE,
encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = encryptionKeyPair.privateKey.serialize(),
disappearingTimer = 0L,
joinedAt = (formationTimestamp / 1000L)
)
// shouldn't exist, don't use getOrConstruct + copy
userGroups.set(groupInfo)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
override fun updateGroupConfig(groupPublicKey: String) {
val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey)
val groupAddress = fromSerialized(groupID)
// TODO: probably add a check in here for isActive?
// TODO: also check if local user is a member / maybe run delete otherwise?
val existingGroup = getGroup(groupID)
?: return Log.w("Loki-DBG", "No existing group for ${groupPublicKey.take(4)}} when updating group config")
val userGroups = configFactory.userGroups ?: return
if (!existingGroup.isActive) {
userGroups.eraseLegacyGroup(groupPublicKey)
return
}
val name = existingGroup.title
val admins = existingGroup.admins.map { it.serialize() }
val members = existingGroup.members.map { it.serialize() }
val membersMap = GroupUtil.createConfigMemberMap(admins = admins, members = members)
val latestKeyPair = getLatestClosedGroupEncryptionKeyPair(groupPublicKey)
?: return Log.w("Loki-DBG", "No latest closed group encryption key pair for ${groupPublicKey.take(4)}} when updating group config")
val recipientSettings = getRecipientSettings(groupAddress) ?: return
val threadID = getThreadId(groupAddress) ?: return
val groupInfo = userGroups.getOrConstructLegacyGroupInfo(groupPublicKey).copy(
name = name,
members = membersMap,
encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = latestKeyPair.privateKey.serialize(),
priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
disappearingTimer = recipientSettings.expireMessages.toLong(),
joinedAt = (existingGroup.formationTimestamp / 1000L)
)
userGroups.set(groupInfo)
}
override fun isGroupActive(groupPublicKey: String): Boolean { override fun isGroupActive(groupPublicKey: String): Boolean {
return DatabaseComponent.get(context).groupDatabase().getGroup(GroupUtil.doubleEncodeGroupID(groupPublicKey)).orNull()?.isActive == true return DatabaseComponent.get(context).groupDatabase().getGroup(GroupUtil.doubleEncodeGroupID(groupPublicKey)).orNull()?.isActive == true
} }
@ -504,7 +921,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON()
val infoMessage = IncomingGroupMessage(m, groupID, updateData, true) val infoMessage = IncomingGroupMessage(m, groupID, updateData, true)
val smsDB = DatabaseComponent.get(context).smsDatabase() val smsDB = DatabaseComponent.get(context).smsDatabase()
smsDB.insertMessageInbox(infoMessage, true, true) smsDB.insertMessageInbox(infoMessage, true)
} }
override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long) { override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long) {
@ -552,8 +969,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseComponent.get(context).lokiAPIDatabase().removeClosedGroupPublicKey(groupPublicKey) DatabaseComponent.get(context).lokiAPIDatabase().removeClosedGroupPublicKey(groupPublicKey)
} }
override fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) { override fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) {
DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey) DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, timestamp)
} }
override fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) { override fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) {
@ -570,9 +987,21 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
.updateTimestampUpdated(groupID, updatedTimestamp) .updateTimestampUpdated(groupID, updatedTimestamp)
} }
override fun setExpirationTimer(groupID: String, duration: Int) { override fun setExpirationTimer(address: String, duration: Int) {
val recipient = Recipient.from(context, fromSerialized(groupID), false) val recipient = Recipient.from(context, fromSerialized(address), false)
DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration)
if (recipient.isContactRecipient && !recipient.isLocalNumber) {
configFactory.contacts?.upsertContact(address) {
this.expiryMode = if (duration != 0) {
ExpiryMode.AfterRead(duration.toLong())
} else { // = 0 / delete
ExpiryMode.NONE
}
}
if (configFactory.contacts?.needsPush() == true) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
}
} }
override fun setServerCapabilities(server: String, capabilities: List<String>) { override fun setServerCapabilities(server: String, capabilities: List<String>) {
@ -591,16 +1020,29 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
OpenGroupManager.updateOpenGroup(openGroup, context) OpenGroupManager.updateOpenGroup(openGroup, context)
} }
override fun getAllGroups(): List<GroupRecord> { override fun getAllGroups(includeInactive: Boolean): List<GroupRecord> {
return DatabaseComponent.get(context).groupDatabase().allGroups return DatabaseComponent.get(context).groupDatabase().getAllGroups(includeInactive)
} }
override fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? { override fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? {
return OpenGroupManager.addOpenGroup(urlAsString, context) return OpenGroupManager.addOpenGroup(urlAsString, context)
} }
override fun onOpenGroupAdded(server: String) { override fun onOpenGroupAdded(server: String, room: String) {
OpenGroupManager.restartPollerForServer(server.removeSuffix("/")) OpenGroupManager.restartPollerForServer(server.removeSuffix("/"))
val groups = configFactory.userGroups ?: return
val volatileConfig = configFactory.convoVolatile ?: return
val openGroup = getOpenGroup(room, server) ?: return
val (infoServer, infoRoom, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return
val pubKeyHex = Hex.toStringCondensed(pubKey)
val communityInfo = groups.getOrConstructCommunityInfo(infoServer, infoRoom, pubKeyHex)
groups.set(communityInfo)
val volatile = volatileConfig.getOrConstructCommunity(infoServer, infoRoom, pubKey)
if (volatile.lastRead != 0L) {
val threadId = getThreadId(openGroup) ?: return
markConversationAsRead(threadId, volatile.lastRead, force = true)
}
volatileConfig.set(volatile)
} }
override fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean { override fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean {
@ -618,17 +1060,19 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient)
} }
override fun getOrCreateThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?): Long { override fun getThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?, createThread: Boolean): Long? {
val database = DatabaseComponent.get(context).threadDatabase() val database = DatabaseComponent.get(context).threadDatabase()
return if (!openGroupID.isNullOrEmpty()) { return if (!openGroupID.isNullOrEmpty()) {
val recipient = Recipient.from(context, fromSerialized(GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())), false) val recipient = Recipient.from(context, fromSerialized(GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())), false)
database.getThreadIdIfExistsFor(recipient) database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it }
} else if (!groupPublicKey.isNullOrEmpty()) { } else if (!groupPublicKey.isNullOrEmpty()) {
val recipient = Recipient.from(context, fromSerialized(GroupUtil.doubleEncodeGroupID(groupPublicKey)), false) val recipient = Recipient.from(context, fromSerialized(GroupUtil.doubleEncodeGroupID(groupPublicKey)), false)
database.getOrCreateThreadIdFor(recipient) if (createThread) database.getOrCreateThreadIdFor(recipient)
else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it }
} else { } else {
val recipient = Recipient.from(context, fromSerialized(publicKey), false) val recipient = Recipient.from(context, fromSerialized(publicKey), false)
database.getOrCreateThreadIdFor(recipient) if (createThread) database.getOrCreateThreadIdFor(recipient)
else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it }
} }
} }
@ -637,6 +1081,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return getThreadId(address) return getThreadId(address)
} }
override fun getThreadId(openGroup: OpenGroup): Long? {
return GroupManager.getOpenGroupThreadID("${openGroup.server.removeSuffix("/")}.${openGroup.room}", context)
}
override fun getThreadId(address: Address): Long? { override fun getThreadId(address: Address): Long? {
val recipient = Recipient.from(context, address, false) val recipient = Recipient.from(context, address, false)
return getThreadId(recipient) return getThreadId(recipient)
@ -666,6 +1114,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
override fun setContact(contact: Contact) { override fun setContact(contact: Contact) {
DatabaseComponent.get(context).sessionContactDatabase().setContact(contact) DatabaseComponent.get(context).sessionContactDatabase().setContact(contact)
val address = fromSerialized(contact.sessionID)
if (!getRecipientApproved(address)) return
val recipientHash = SSKEnvironment.shared.profileManager.contactUpdatedInternal(contact)
val recipient = Recipient.from(context, address, false)
setRecipientHash(recipient, recipientHash)
} }
override fun getRecipientForThread(threadId: Long): Recipient? { override fun getRecipientForThread(threadId: Long): Recipient? {
@ -677,6 +1130,51 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return if (recipientSettings.isPresent) { recipientSettings.get() } else null return if (recipientSettings.isPresent) { recipientSettings.get() } else null
} }
override fun addLibSessionContacts(contacts: List<LibSessionContact>) {
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val moreContacts = contacts.filter { contact ->
val id = SessionId(contact.id)
id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.sessionId != null }
}
val profileManager = SSKEnvironment.shared.profileManager
moreContacts.forEach { contact ->
val address = fromSerialized(contact.id)
val recipient = Recipient.from(context, address, false)
setBlocked(listOf(recipient), contact.blocked, fromConfigUpdate = true)
setRecipientApproved(recipient, contact.approved)
setRecipientApprovedMe(recipient, contact.approvedMe)
if (contact.name.isNotEmpty()) {
profileManager.setName(context, recipient, contact.name)
} else {
profileManager.setName(context, recipient, null)
}
if (contact.nickname.isNotEmpty()) {
profileManager.setNickname(context, recipient, contact.nickname)
} else {
profileManager.setNickname(context, recipient, null)
}
if (contact.profilePicture != UserPic.DEFAULT) {
val (url, key) = contact.profilePicture
if (key.size != ProfileKeyUtil.PROFILE_KEY_BYTES) return@forEach
profileManager.setProfilePicture(context, recipient, url, key)
profileManager.setUnidentifiedAccessMode(context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN)
} else {
profileManager.setProfilePicture(context, recipient, null, null)
}
if (contact.priority == PRIORITY_HIDDEN) {
getThreadId(fromSerialized(contact.id))?.let { conversationThreadId ->
deleteConversation(conversationThreadId)
}
} else {
getThreadId(fromSerialized(contact.id))?.let { conversationThreadId ->
setPinned(conversationThreadId, contact.priority == PRIORITY_PINNED)
}
}
setRecipientHash(recipient, contact.hashCode().toString())
}
}
override fun addContacts(contacts: List<ConfigurationMessage.Contact>) { override fun addContacts(contacts: List<ConfigurationMessage.Contact>) {
val recipientDatabase = DatabaseComponent.get(context).recipientDatabase() val recipientDatabase = DatabaseComponent.get(context).recipientDatabase()
val threadDatabase = DatabaseComponent.get(context).threadDatabase() val threadDatabase = DatabaseComponent.get(context).threadDatabase()
@ -700,19 +1198,18 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
recipientDatabase.setProfileSharing(recipient, true) recipientDatabase.setProfileSharing(recipient, true)
recipientDatabase.setRegistered(recipient, Recipient.RegisteredState.REGISTERED) recipientDatabase.setRegistered(recipient, Recipient.RegisteredState.REGISTERED)
// create Thread if needed // create Thread if needed
val threadId = threadDatabase.getOrCreateThreadIdFor(recipient) val threadId = threadDatabase.getThreadIdIfExistsFor(recipient)
if (contact.didApproveMe == true) { if (contact.didApproveMe == true) {
recipientDatabase.setApprovedMe(recipient, true) recipientDatabase.setApprovedMe(recipient, true)
} }
if (contact.isApproved == true) { if (contact.isApproved == true && threadId != -1L) {
recipientDatabase.setApproved(recipient, true) setRecipientApproved(recipient, true)
threadDatabase.setHasSent(threadId, true) threadDatabase.setHasSent(threadId, true)
} }
val contactIsBlocked: Boolean? = contact.isBlocked val contactIsBlocked: Boolean? = contact.isBlocked
if (contactIsBlocked != null && recipient.isBlocked != contactIsBlocked) { if (contactIsBlocked != null && recipient.isBlocked != contactIsBlocked) {
recipientDatabase.setBlocked(recipient, contactIsBlocked) setBlocked(listOf(recipient), contactIsBlocked, fromConfigUpdate = true)
threadDatabase.deleteConversation(threadId)
} }
} }
if (contacts.isNotEmpty()) { if (contacts.isNotEmpty()) {
@ -720,6 +1217,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
} }
} }
override fun setRecipientHash(recipient: Recipient, recipientHash: String?) {
val recipientDb = DatabaseComponent.get(context).recipientDatabase()
recipientDb.setRecipientHash(recipient, recipientHash)
}
override fun getLastUpdated(threadID: Long): Long { override fun getLastUpdated(threadID: Long): Long {
val threadDB = DatabaseComponent.get(context).threadDatabase() val threadDB = DatabaseComponent.get(context).threadDatabase()
return threadDB.getLastUpdated(threadID) return threadDB.getLastUpdated(threadID)
@ -740,12 +1242,78 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
return mmsSmsDb.getConversationCount(threadID) return mmsSmsDb.getConversationCount(threadID)
} }
override fun deleteConversation(threadId: Long) { override fun setPinned(threadID: Long, isPinned: Boolean) {
val threadDB = DatabaseComponent.get(context).threadDatabase() val threadDB = DatabaseComponent.get(context).threadDatabase()
threadDB.deleteConversation(threadId) threadDB.setPinned(threadID, isPinned)
val threadRecipient = getRecipientForThread(threadID) ?: return
if (threadRecipient.isLocalNumber) {
val user = configFactory.user ?: return
user.setNtsPriority(if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE)
} else if (threadRecipient.isContactRecipient) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(threadRecipient.address.serialize()) {
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
}
} else if (threadRecipient.isGroupRecipient) {
val groups = configFactory.userGroups ?: return
if (threadRecipient.isClosedGroupRecipient) {
val sessionId = GroupUtil.doubleDecodeGroupId(threadRecipient.address.serialize())
val newGroupInfo = groups.getOrConstructLegacyGroupInfo(sessionId).copy (
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
)
groups.set(newGroupInfo)
} else if (threadRecipient.isOpenGroupRecipient) {
val openGroup = getOpenGroup(threadID) ?: return
val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return
val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy (
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
)
groups.set(newGroupInfo)
}
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
} }
override fun isPinned(threadID: Long): Boolean {
val threadDB = DatabaseComponent.get(context).threadDatabase()
return threadDB.isPinned(threadID)
}
override fun setThreadDate(threadId: Long, newDate: Long) {
val threadDb = DatabaseComponent.get(context).threadDatabase()
threadDb.setDate(threadId, newDate)
}
override fun deleteConversation(threadID: Long) {
val recipient = getRecipientForThread(threadID)
val threadDB = DatabaseComponent.get(context).threadDatabase()
val groupDB = DatabaseComponent.get(context).groupDatabase()
threadDB.deleteConversation(threadID)
if (recipient != null) {
if (recipient.isContactRecipient) {
if (recipient.isLocalNumber) return
val contacts = configFactory.contacts ?: return
contacts.upsertContact(recipient.address.serialize()) {
this.priority = PRIORITY_HIDDEN
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
} else if (recipient.isClosedGroupRecipient) {
// TODO: handle closed group
val volatile = configFactory.convoVolatile ?: return
val groups = configFactory.userGroups ?: return
val groupID = recipient.address.toGroupString()
val closedGroup = getGroup(groupID)
val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
if (closedGroup != null) {
groupDB.delete(groupID) // TODO: Should we delete the group? (seems odd to leave it)
volatile.eraseLegacyClosedGroup(groupPublicKey)
groups.eraseLegacyGroup(groupPublicKey)
} else {
Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}")
}
}
}
}
override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri { override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri {
return PartAuthority.getAttachmentDataUri(attachmentId) return PartAuthority.getAttachmentDataUri(attachmentId)
@ -762,6 +1330,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
if (recipient.isBlocked) return if (recipient.isBlocked) return
val threadId = getThreadId(recipient) ?: return
val mediaMessage = IncomingMediaMessage( val mediaMessage = IncomingMediaMessage(
address, address,
sentTimestamp, sentTimestamp,
@ -780,14 +1350,21 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
Optional.of(message) Optional.of(message)
) )
database.insertSecureDecryptedMessageInbox(mediaMessage, -1, runIncrement = true, runThreadUpdate = true) database.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true)
} }
override fun insertMessageRequestResponse(response: MessageRequestResponse) { override fun insertMessageRequestResponse(response: MessageRequestResponse) {
val userPublicKey = getUserPublicKey() val userPublicKey = getUserPublicKey()
val senderPublicKey = response.sender!! val senderPublicKey = response.sender!!
val recipientPublicKey = response.recipient!! val recipientPublicKey = response.recipient!!
if (userPublicKey == null || (userPublicKey != recipientPublicKey && userPublicKey != senderPublicKey)) return
if (
userPublicKey == null
|| (userPublicKey != recipientPublicKey && userPublicKey != senderPublicKey)
// this is true if it is a sync message
|| (userPublicKey == recipientPublicKey && userPublicKey == senderPublicKey)
) return
val recipientDb = DatabaseComponent.get(context).recipientDatabase() val recipientDb = DatabaseComponent.get(context).recipientDatabase()
val threadDB = DatabaseComponent.get(context).threadDatabase() val threadDB = DatabaseComponent.get(context).threadDatabase()
if (userPublicKey == senderPublicKey) { if (userPublicKey == senderPublicKey) {
@ -799,7 +1376,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val mmsDb = DatabaseComponent.get(context).mmsDatabase() val mmsDb = DatabaseComponent.get(context).mmsDatabase()
val smsDb = DatabaseComponent.get(context).smsDatabase() val smsDb = DatabaseComponent.get(context).smsDatabase()
val sender = Recipient.from(context, fromSerialized(senderPublicKey), false) val sender = Recipient.from(context, fromSerialized(senderPublicKey), false)
val threadId = threadDB.getOrCreateThreadIdFor(sender) val threadId = getOrCreateThreadIdFor(sender.address)
val profile = response.profile val profile = response.profile
if (profile != null) { if (profile != null) {
val profileManager = SSKEnvironment.shared.profileManager val profileManager = SSKEnvironment.shared.profileManager
@ -814,9 +1391,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
val profileKeyChanged = (sender.profileKey == null || !MessageDigest.isEqual(sender.profileKey, newProfileKey)) val profileKeyChanged = (sender.profileKey == null || !MessageDigest.isEqual(sender.profileKey, newProfileKey))
if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) { if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) {
profileManager.setProfileKey(context, sender, newProfileKey!!) profileManager.setProfilePicture(context, sender, profile.profilePictureURL!!, newProfileKey!!)
profileManager.setUnidentifiedAccessMode(context, sender, Recipient.UnidentifiedAccessMode.UNKNOWN) profileManager.setUnidentifiedAccessMode(context, sender, Recipient.UnidentifiedAccessMode.UNKNOWN)
profileManager.setProfilePictureURL(context, sender, profile.profilePictureURL!!)
} }
} }
threadDB.setHasSent(threadId, true) threadDB.setHasSent(threadId, true)
@ -873,16 +1449,28 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
Optional.absent(), Optional.absent(),
Optional.absent() Optional.absent()
) )
mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runIncrement = true, runThreadUpdate = true) mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = true)
} }
} }
override fun getRecipientApproved(address: Address): Boolean {
return DatabaseComponent.get(context).recipientDatabase().getApproved(address)
}
override fun setRecipientApproved(recipient: Recipient, approved: Boolean) { override fun setRecipientApproved(recipient: Recipient, approved: Boolean) {
DatabaseComponent.get(context).recipientDatabase().setApproved(recipient, approved) DatabaseComponent.get(context).recipientDatabase().setApproved(recipient, approved)
if (recipient.isLocalNumber || !recipient.isContactRecipient) return
configFactory.contacts?.upsertContact(recipient.address.serialize()) {
this.approved = approved
}
} }
override fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean) { override fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean) {
DatabaseComponent.get(context).recipientDatabase().setApprovedMe(recipient, approvedMe) DatabaseComponent.get(context).recipientDatabase().setApprovedMe(recipient, approvedMe)
if (recipient.isLocalNumber || !recipient.isContactRecipient) return
configFactory.contacts?.upsertContact(recipient.address.serialize()) {
this.approvedMe = approvedMe
}
} }
override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) { override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) {
@ -1012,9 +1600,18 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context,
DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms)) DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms))
} }
override fun unblock(toUnblock: Iterable<Recipient>) { override fun setBlocked(recipients: Iterable<Recipient>, isBlocked: Boolean, fromConfigUpdate: Boolean) {
val recipientDb = DatabaseComponent.get(context).recipientDatabase() val recipientDb = DatabaseComponent.get(context).recipientDatabase()
recipientDb.setBlocked(toUnblock, false) recipientDb.setBlocked(recipients, isBlocked)
recipients.filter { it.isContactRecipient && !it.isLocalNumber }.forEach { recipient ->
configFactory.contacts?.upsertContact(recipient.address.serialize()) {
this.blocked = isBlocked
}
}
val contactsConfig = configFactory.contacts ?: return
if (contactsConfig.needsPush() && !fromConfigUpdate) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
} }
override fun blockedContacts(): List<Recipient> { override fun blockedContacts(): List<Recipient> {

View File

@ -64,7 +64,6 @@ import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.util.SessionMetaProtocol; import org.thoughtcrime.securesms.util.SessionMetaProtocol;
import java.io.Closeable; import java.io.Closeable;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedList; import java.util.LinkedList;
@ -74,6 +73,11 @@ import java.util.Set;
public class ThreadDatabase extends Database { public class ThreadDatabase extends Database {
public interface ConversationThreadUpdateListener {
void threadCreated(@NonNull Address address, long threadId);
void threadDeleted(@NonNull Address address, long threadId);
}
private static final String TAG = ThreadDatabase.class.getSimpleName(); private static final String TAG = ThreadDatabase.class.getSimpleName();
private final Map<Long, Address> addressCache = new HashMap<>(); private final Map<Long, Address> addressCache = new HashMap<>();
@ -141,10 +145,16 @@ public class ThreadDatabase extends Database {
"ADD COLUMN " + UNREAD_MENTION_COUNT + " INTEGER DEFAULT 0;"; "ADD COLUMN " + UNREAD_MENTION_COUNT + " INTEGER DEFAULT 0;";
} }
private ConversationThreadUpdateListener updateListener;
public ThreadDatabase(Context context, SQLCipherOpenHelper databaseHelper) { public ThreadDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper); super(context, databaseHelper);
} }
public void setUpdateListener(ConversationThreadUpdateListener updateListener) {
this.updateListener = updateListener;
}
private long createThreadForRecipient(Address address, boolean group, int distributionType) { private long createThreadForRecipient(Address address, boolean group, int distributionType) {
ContentValues contentValues = new ContentValues(4); ContentValues contentValues = new ContentValues(4);
long date = SnodeAPI.getNowWithOffset(); long date = SnodeAPI.getNowWithOffset();
@ -207,10 +217,14 @@ public class ThreadDatabase extends Database {
} }
private void deleteThread(long threadId) { private void deleteThread(long threadId) {
Recipient recipient = getRecipientForThreadId(threadId);
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(TABLE_NAME, ID_WHERE, new String[] {threadId + ""}); int numberRemoved = db.delete(TABLE_NAME, ID_WHERE, new String[] {threadId + ""});
addressCache.remove(threadId); addressCache.remove(threadId);
notifyConversationListListeners(); notifyConversationListListeners();
if (updateListener != null && numberRemoved > 0 && recipient != null) {
updateListener.threadDeleted(recipient.getAddress(), threadId);
}
} }
private void deleteThreads(Set<Long> threadIds) { private void deleteThreads(Set<Long> threadIds) {
@ -278,7 +292,7 @@ public class ThreadDatabase extends Database {
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate);
DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate);
update(threadId, false); update(threadId, false, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
} }
} finally { } finally {
@ -291,10 +305,34 @@ public class ThreadDatabase extends Database {
Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp); Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp);
DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp);
DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp);
update(threadId, false); update(threadId, false, true);
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
} }
public List<MarkedMessageInfo> setRead(long threadId, long lastReadTime) {
final List<MarkedMessageInfo> smsRecords = DatabaseComponent.get(context).smsDatabase().setMessagesRead(threadId, lastReadTime);
final List<MarkedMessageInfo> mmsRecords = DatabaseComponent.get(context).mmsDatabase().setMessagesRead(threadId, lastReadTime);
if (smsRecords.isEmpty() && mmsRecords.isEmpty()) {
return Collections.emptyList();
}
ContentValues contentValues = new ContentValues(2);
contentValues.put(READ, smsRecords.isEmpty() && mmsRecords.isEmpty());
contentValues.put(LAST_SEEN, lastReadTime);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""});
notifyConversationListListeners();
return new LinkedList<MarkedMessageInfo>() {{
addAll(smsRecords);
addAll(mmsRecords);
}};
}
public List<MarkedMessageInfo> setRead(long threadId, boolean lastSeen) { public List<MarkedMessageInfo> setRead(long threadId, boolean lastSeen) {
ContentValues contentValues = new ContentValues(1); ContentValues contentValues = new ContentValues(1);
contentValues.put(READ, 1); contentValues.put(READ, 1);
@ -319,30 +357,6 @@ public class ThreadDatabase extends Database {
}}; }};
} }
public void incrementUnread(long threadId, int amount, int unreadMentionAmount) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = 0, " +
UNREAD_COUNT + " = " + UNREAD_COUNT + " + ?, " +
UNREAD_MENTION_COUNT + " = " + UNREAD_MENTION_COUNT + " + ? WHERE " + ID + " = ?",
new String[] {
String.valueOf(amount),
String.valueOf(unreadMentionAmount),
String.valueOf(threadId)
});
}
public void decrementUnread(long threadId, int amount, int unreadMentionAmount) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = 0, " +
UNREAD_COUNT + " = " + UNREAD_COUNT + " - ?, " +
UNREAD_MENTION_COUNT + " = " + UNREAD_MENTION_COUNT + " - ? WHERE " + ID + " = ? AND " + UNREAD_COUNT + " > 0",
new String[] {
String.valueOf(amount),
String.valueOf(unreadMentionAmount),
String.valueOf(threadId)
});
}
public void setDistributionType(long threadId, int distributionType) { public void setDistributionType(long threadId, int distributionType) {
ContentValues contentValues = new ContentValues(1); ContentValues contentValues = new ContentValues(1);
contentValues.put(TYPE, distributionType); contentValues.put(TYPE, distributionType);
@ -352,6 +366,14 @@ public class ThreadDatabase extends Database {
notifyConversationListListeners(); notifyConversationListListeners();
} }
public void setDate(long threadId, long date) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(DATE, date);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
int updated = db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""});
if (updated > 0) notifyConversationListListeners();
}
public int getDistributionType(long threadId) { public int getDistributionType(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase(); SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = db.query(TABLE_NAME, new String[]{TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); Cursor cursor = db.query(TABLE_NAME, new String[]{TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
@ -427,9 +449,9 @@ public class ThreadDatabase extends Database {
" ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS + " ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
" WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + MESSAGE_COUNT + " = " + UNREAD_COUNT + " AND " + " WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL"; GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL";
cursor = db.rawQuery(query, null); cursor = db.rawQuery(query, null);
@ -481,7 +503,7 @@ public class ThreadDatabase extends Database {
} }
public Cursor getApprovedConversationList() { public Cursor getApprovedConversationList() {
String where = "((" + MESSAGE_COUNT + " != 0 AND (" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%')) OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " + String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " +
"AND " + ARCHIVED + " = 0 "; "AND " + ARCHIVED + " = 0 ";
return getConversationList(where); return getConversationList(where);
} }
@ -517,21 +539,50 @@ public class ThreadDatabase extends Database {
return db.rawQuery(query, null); return db.rawQuery(query, null);
} }
public void setLastSeen(long threadId, long timestamp) { /**
SQLiteDatabase db = databaseHelper.getWritableDatabase(); * @param threadId
ContentValues contentValues = new ContentValues(1); * @param timestamp
if (timestamp == -1) { * @return true if we have set the last seen for the thread, false if there were no messages in the thread
contentValues.put(LAST_SEEN, SnodeAPI.getNowWithOffset()); */
} else { public boolean setLastSeen(long threadId, long timestamp) {
contentValues.put(LAST_SEEN, timestamp); // edge case where we set the last seen time for a conversation before it loads messages (joining community for example)
} MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
Recipient forThreadId = getRecipientForThreadId(threadId);
if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && forThreadId != null && forThreadId.isOpenGroupRecipient()) return false;
SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues contentValues = new ContentValues(1);
long lastSeenTime = timestamp == -1 ? SnodeAPI.getNowWithOffset() : timestamp;
contentValues.put(LAST_SEEN, lastSeenTime);
db.beginTransaction();
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)}); db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)});
String smsCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.READ+" = 0";
String smsMentionCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.READ+" = 0 AND s."+SmsDatabase.HAS_MENTION+" = 1";
String smsReactionCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.REACTIONS_UNREAD+" = 1";
String mmsCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.READ+" = 0";
String mmsMentionCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.READ+" = 0 AND m."+MmsDatabase.HAS_MENTION+" = 1";
String mmsReactionCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.REACTIONS_UNREAD+" = 1";
String allSmsUnread = "(("+smsCountSubQuery+") + ("+smsReactionCountSubQuery+"))";
String allMmsUnread = "(("+mmsCountSubQuery+") + ("+mmsReactionCountSubQuery+"))";
String allUnread = "(("+allSmsUnread+") + ("+allMmsUnread+"))";
String allUnreadMention = "(("+smsMentionCountSubQuery+") + ("+mmsMentionCountSubQuery+"))";
String reflectUpdates = "UPDATE "+TABLE_NAME+" AS t SET "+UNREAD_COUNT+" = "+allUnread+", "+UNREAD_MENTION_COUNT+" = "+allUnreadMention+" WHERE "+ID+" = ?";
db.execSQL(reflectUpdates, new Object[]{threadId});
db.setTransactionSuccessful();
db.endTransaction();
notifyConversationListeners(threadId);
notifyConversationListListeners(); notifyConversationListListeners();
return true;
} }
public void setLastSeen(long threadId) { /**
setLastSeen(threadId, -1); * @param threadId
* @return true if we have set the last seen for the thread, false if there were no messages in the thread
*/
public boolean setLastSeen(long threadId) {
return setLastSeen(threadId, -1);
} }
public Pair<Long, Boolean> getLastSeenAndHasSent(long threadId) { public Pair<Long, Boolean> getLastSeenAndHasSent(long threadId) {
@ -634,13 +685,19 @@ public class ThreadDatabase extends Database {
try { try {
cursor = db.query(TABLE_NAME, new String[]{ID}, where, recipientsArg, null, null, null); cursor = db.query(TABLE_NAME, new String[]{ID}, where, recipientsArg, null, null, null);
long threadId;
boolean created = false;
if (cursor != null && cursor.moveToFirst()) { if (cursor != null && cursor.moveToFirst()) {
return cursor.getLong(cursor.getColumnIndexOrThrow(ID)); threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ID));
} else { } else {
DatabaseComponent.get(context).recipientDatabase().setProfileSharing(recipient, true); DatabaseComponent.get(context).recipientDatabase().setProfileSharing(recipient, true);
return createThreadForRecipient(recipient.getAddress(), recipient.isGroupRecipient(), distributionType); threadId = createThreadForRecipient(recipient.getAddress(), recipient.isGroupRecipient(), distributionType);
created = true;
} }
if (created && updateListener != null) {
updateListener.threadCreated(recipient.getAddress(), threadId);
}
return threadId;
} finally { } finally {
if (cursor != null) if (cursor != null)
cursor.close(); cursor.close();
@ -679,13 +736,14 @@ public class ThreadDatabase extends Database {
new String[] {String.valueOf(threadId)}); new String[] {String.valueOf(threadId)});
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
notifyConversationListListeners();
} }
public boolean update(long threadId, boolean unarchive) { public boolean update(long threadId, boolean unarchive, boolean shouldDeleteOnEmpty) {
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
long count = mmsSmsDatabase.getConversationCount(threadId); long count = mmsSmsDatabase.getConversationCount(threadId);
boolean shouldDeleteEmptyThread = deleteThreadOnEmpty(threadId); boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && deleteThreadOnEmpty(threadId);
if (count == 0 && shouldDeleteEmptyThread) { if (count == 0 && shouldDeleteEmptyThread) {
deleteThread(threadId); deleteThread(threadId);
@ -708,12 +766,10 @@ public class ThreadDatabase extends Database {
updateThread(threadId, count, getFormattedBodyFor(record), getAttachmentUriFor(record), updateThread(threadId, count, getFormattedBodyFor(record), getAttachmentUriFor(record),
record.getTimestamp(), record.getDeliveryStatus(), record.getDeliveryReceiptCount(), record.getTimestamp(), record.getDeliveryStatus(), record.getDeliveryReceiptCount(),
record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount()); record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount());
notifyConversationListListeners();
return false; return false;
} else { } else {
if (shouldDeleteEmptyThread) { if (shouldDeleteEmptyThread) {
deleteThread(threadId); deleteThread(threadId);
notifyConversationListListeners();
return true; return true;
} }
return false; return false;
@ -721,6 +777,8 @@ public class ThreadDatabase extends Database {
} finally { } finally {
if (reader != null) if (reader != null)
reader.close(); reader.close();
notifyConversationListListeners();
notifyConversationListeners(threadId);
} }
} }
@ -732,10 +790,32 @@ public class ThreadDatabase extends Database {
new String[] {String.valueOf(threadId)}); new String[] {String.valueOf(threadId)});
notifyConversationListeners(threadId); notifyConversationListeners(threadId);
notifyConversationListListeners();
} }
public void markAllAsRead(long threadId, boolean isGroupRecipient) { public boolean isPinned(long threadId) {
List<MarkedMessageInfo> messages = setRead(threadId, true); SQLiteDatabase db = getReadableDatabase();
Cursor cursor = db.query(TABLE_NAME, new String[]{IS_PINNED}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
try {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0) == 1;
}
return false;
} finally {
if (cursor != null) cursor.close();
}
}
/**
* @param threadId
* @param isGroupRecipient
* @param lastSeenTime
* @return true if we have set the last seen for the thread, false if there were no messages in the thread
*/
public boolean markAllAsRead(long threadId, boolean isGroupRecipient, long lastSeenTime, boolean force) {
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && !force) return false;
List<MarkedMessageInfo> messages = setRead(threadId, lastSeenTime);
if (isGroupRecipient) { if (isGroupRecipient) {
for (MarkedMessageInfo message: messages) { for (MarkedMessageInfo message: messages) {
MarkReadReceiver.scheduleDeletion(context, message.getExpirationInfo()); MarkReadReceiver.scheduleDeletion(context, message.getExpirationInfo());
@ -743,7 +823,8 @@ public class ThreadDatabase extends Database {
} else { } else {
MarkReadReceiver.process(context, messages); MarkReadReceiver.process(context, messages);
} }
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, false, 0); ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId);
return setLastSeen(threadId, lastSeenTime);
} }
private boolean deleteThreadOnEmpty(long threadId) { private boolean deleteThreadOnEmpty(long threadId) {

View File

@ -11,7 +11,6 @@ import androidx.core.app.NotificationCompat;
import net.zetetic.database.sqlcipher.SQLiteConnection; import net.zetetic.database.sqlcipher.SQLiteConnection;
import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteDatabaseHook; import net.zetetic.database.sqlcipher.SQLiteDatabaseHook;
import net.zetetic.database.sqlcipher.SQLiteException;
import net.zetetic.database.sqlcipher.SQLiteOpenHelper; import net.zetetic.database.sqlcipher.SQLiteOpenHelper;
import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.TextSecurePreferences;
@ -19,6 +18,7 @@ import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.crypto.DatabaseSecret; import org.thoughtcrime.securesms.crypto.DatabaseSecret;
import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase; import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase;
import org.thoughtcrime.securesms.database.ConfigDatabase;
import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.DraftDatabase;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase; import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase;
@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.database.SessionJobDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities;
import java.io.File; import java.io.File;
@ -85,9 +86,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV38 = 59; private static final int lokiV38 = 59;
private static final int lokiV39 = 60; private static final int lokiV39 = 60;
private static final int lokiV40 = 61; private static final int lokiV40 = 61;
private static final int lokiV41 = 62;
private static final int lokiV42 = 63;
// Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes
private static final int DATABASE_VERSION = lokiV40; private static final int DATABASE_VERSION = lokiV42;
private static final int MIN_DATABASE_VERSION = lokiV7; private static final int MIN_DATABASE_VERSION = lokiV7;
private static final String CIPHER3_DATABASE_NAME = "signal.db"; private static final String CIPHER3_DATABASE_NAME = "signal.db";
public static final String DATABASE_NAME = "signal_v4.db"; public static final String DATABASE_NAME = "signal_v4.db";
@ -147,7 +150,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
connection.execute("PRAGMA cipher_page_size = 4096;", null, null); connection.execute("PRAGMA cipher_page_size = 4096;", null, null);
} }
private static SQLiteDatabase open(String path, DatabaseSecret databaseSecret, boolean useSQLCipher4) throws SQLiteException { private static SQLiteDatabase open(String path, DatabaseSecret databaseSecret, boolean useSQLCipher4) {
return SQLiteDatabase.openDatabase(path, databaseSecret.asString(), null, SQLiteDatabase.OPEN_READWRITE, new SQLiteDatabaseHook() { return SQLiteDatabase.openDatabase(path, databaseSecret.asString(), null, SQLiteDatabase.OPEN_READWRITE, new SQLiteDatabaseHook() {
@Override @Override
public void preKey(SQLiteConnection connection) { SQLCipherOpenHelper.applySQLCipherPragmas(connection, useSQLCipher4); } public void preKey(SQLiteConnection connection) { SQLCipherOpenHelper.applySQLCipherPragmas(connection, useSQLCipher4); }
@ -340,6 +343,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(ThreadDatabase.getUnreadMentionCountCommand()); db.execSQL(ThreadDatabase.getUnreadMentionCountCommand());
db.execSQL(SmsDatabase.CREATE_HAS_MENTION_COMMAND); db.execSQL(SmsDatabase.CREATE_HAS_MENTION_COMMAND);
db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND); db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND);
db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND);
executeStatements(db, SmsDatabase.CREATE_INDEXS); executeStatements(db, SmsDatabase.CREATE_INDEXS);
executeStatements(db, MmsDatabase.CREATE_INDEXS); executeStatements(db, MmsDatabase.CREATE_INDEXS);
@ -351,6 +355,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
executeStatements(db, ReactionDatabase.CREATE_INDEXS); executeStatements(db, ReactionDatabase.CREATE_INDEXS);
executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS); executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS);
db.execSQL(RecipientDatabase.getAddWrapperHash());
} }
@Override @Override
@ -583,6 +588,16 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND); db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND);
} }
if (oldVersion < lokiV41) {
db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND);
db.execSQL(ConfigurationMessageUtilities.DELETE_INACTIVE_GROUPS);
db.execSQL(ConfigurationMessageUtilities.DELETE_INACTIVE_ONE_TO_ONES);
}
if (oldVersion < lokiV42) {
db.execSQL(RecipientDatabase.getAddWrapperHash());
}
db.setTransactionSuccessful(); db.setTransactionSuccessful();
} finally { } finally {
db.endTransaction(); db.endTransaction();

View File

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.dependencies
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.AppTextSecurePreferences
@ -19,4 +20,10 @@ abstract class AppModule {
@Binds @Binds
abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AppComponent {
fun getPrefs(): TextSecurePreferences
} }

View File

@ -0,0 +1,251 @@
package org.thoughtcrime.securesms.dependencies
import android.content.Context
import android.os.Trace
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.ConfigFactoryUpdateListener
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.ConfigDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
class ConfigFactory(
private val context: Context,
private val configDatabase: ConfigDatabase,
private val maybeGetUserInfo: () -> Pair<ByteArray, String>?
) :
ConfigFactoryProtocol {
companion object {
// This is a buffer period within which we will process messages which would result in a
// config change, any message which would normally result in a config change which was sent
// before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not actually have
// it's changes applied (control text will still be added though)
val configChangeBufferPeriod: Long = (2 * 60 * 1000)
}
fun keyPairChanged() { // this should only happen restoring or clearing data
_userConfig?.free()
_contacts?.free()
_convoVolatileConfig?.free()
_userConfig = null
_contacts = null
_convoVolatileConfig = null
}
private val userLock = Object()
private var _userConfig: UserProfile? = null
private val contactsLock = Object()
private var _contacts: Contacts? = null
private val convoVolatileLock = Object()
private var _convoVolatileConfig: ConversationVolatileConfig? = null
private val userGroupsLock = Object()
private var _userGroups: UserGroupsConfig? = null
private val isConfigForcedOn by lazy { TextSecurePreferences.hasForcedNewConfig(context) }
private val listeners: MutableList<ConfigFactoryUpdateListener> = mutableListOf()
fun registerListener(listener: ConfigFactoryUpdateListener) {
listeners += listener
}
fun unregisterListener(listener: ConfigFactoryUpdateListener) {
listeners -= listener
}
private inline fun <T> synchronizedWithLog(lock: Any, body: ()->T): T {
Trace.beginSection("synchronizedWithLog")
val result = synchronized(lock) {
body()
}
Trace.endSection()
return result
}
override val user: UserProfile?
get() = synchronizedWithLog(userLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_userConfig == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val userDump = configDatabase.retrieveConfigAndHashes(
SharedConfigMessage.Kind.USER_PROFILE.name,
publicKey
)
_userConfig = if (userDump != null) {
UserProfile.newInstance(secretKey, userDump)
} else {
ConfigurationMessageUtilities.generateUserProfileConfigDump()?.let { dump ->
UserProfile.newInstance(secretKey, dump)
} ?: UserProfile.newInstance(secretKey)
}
}
_userConfig
}
override val contacts: Contacts?
get() = synchronizedWithLog(contactsLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_contacts == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val contactsDump = configDatabase.retrieveConfigAndHashes(
SharedConfigMessage.Kind.CONTACTS.name,
publicKey
)
_contacts = if (contactsDump != null) {
Contacts.newInstance(secretKey, contactsDump)
} else {
ConfigurationMessageUtilities.generateContactConfigDump()?.let { dump ->
Contacts.newInstance(secretKey, dump)
} ?: Contacts.newInstance(secretKey)
}
}
_contacts
}
override val convoVolatile: ConversationVolatileConfig?
get() = synchronizedWithLog(convoVolatileLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_convoVolatileConfig == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val convoDump = configDatabase.retrieveConfigAndHashes(
SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name,
publicKey
)
_convoVolatileConfig = if (convoDump != null) {
ConversationVolatileConfig.newInstance(secretKey, convoDump)
} else {
ConfigurationMessageUtilities.generateConversationVolatileDump(context)
?.let { dump ->
ConversationVolatileConfig.newInstance(secretKey, dump)
} ?: ConversationVolatileConfig.newInstance(secretKey)
}
}
_convoVolatileConfig
}
override val userGroups: UserGroupsConfig?
get() = synchronizedWithLog(userGroupsLock) {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null
if (_userGroups == null) {
val (secretKey, publicKey) = maybeGetUserInfo() ?: return null
val userGroupsDump = configDatabase.retrieveConfigAndHashes(
SharedConfigMessage.Kind.GROUPS.name,
publicKey
)
_userGroups = if (userGroupsDump != null) {
UserGroupsConfig.Companion.newInstance(secretKey, userGroupsDump)
} else {
ConfigurationMessageUtilities.generateUserGroupDump(context)?.let { dump ->
UserGroupsConfig.Companion.newInstance(secretKey, dump)
} ?: UserGroupsConfig.newInstance(secretKey)
}
}
_userGroups
}
override fun getUserConfigs(): List<ConfigBase> =
listOfNotNull(user, contacts, convoVolatile, userGroups)
private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) {
val dumped = user?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.USER_PROFILE.name, publicKey, dumped, timestamp)
}
private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) {
val dumped = contacts?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.CONTACTS.name, publicKey, dumped, timestamp)
}
private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) {
val dumped = convoVolatile?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(
SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name,
publicKey,
dumped,
timestamp
)
}
private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) {
val dumped = userGroups?.dump() ?: return
val (_, publicKey) = maybeGetUserInfo() ?: return
configDatabase.storeConfig(SharedConfigMessage.Kind.GROUPS.name, publicKey, dumped, timestamp)
}
override fun persist(forConfigObject: ConfigBase, timestamp: Long) {
try {
listeners.forEach { listener ->
listener.notifyUpdates(forConfigObject)
}
when (forConfigObject) {
is UserProfile -> persistUserConfigDump(timestamp)
is Contacts -> persistContactsConfigDump(timestamp)
is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp)
is UserGroupsConfig -> persistUserGroupsConfigDump(timestamp)
else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet")
}
} catch (e: Exception) {
Log.e("Loki", "failed to persist ${forConfigObject.javaClass.simpleName}", e)
}
}
override fun conversationInConfig(
publicKey: String?,
groupPublicKey: String?,
openGroupId: String?,
visibleOnly: Boolean
): Boolean {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true
val (_, userPublicKey) = maybeGetUserInfo() ?: return true
if (openGroupId != null) {
val userGroups = userGroups ?: return false
val threadId = GroupManager.getOpenGroupThreadID(openGroupId, context)
val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false
// Not handling the `hidden` behaviour for communities so just indicate the existence
return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null)
}
else if (groupPublicKey != null) {
val userGroups = userGroups ?: return false
// Not handling the `hidden` behaviour for legacy groups so just indicate the existence
return (userGroups.getLegacyGroupInfo(groupPublicKey) != null)
}
else if (publicKey == userPublicKey) {
val user = user ?: return false
return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN)
}
else if (publicKey != null) {
val contacts = contacts ?: return false
val targetContact = contacts.get(publicKey) ?: return false
return (!visibleOnly || targetContact.priority != ConfigBase.PRIORITY_HIDDEN)
}
return false
}
override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean {
if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true
val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey)
// Ensure the change occurred after the last config message was handled (minus the buffer period)
return (changeTimestampMs >= (lastUpdateTimestampMs - ConfigFactory.configChangeBufferPeriod))
}
}

View File

@ -45,4 +45,5 @@ interface DatabaseComponent {
fun attachmentProvider(): MessageDataProvider fun attachmentProvider(): MessageDataProvider
fun blindedIdMappingDatabase(): BlindedIdMappingDatabase fun blindedIdMappingDatabase(): BlindedIdMappingDatabase
fun groupMemberDatabase(): GroupMemberDatabase fun groupMemberDatabase(): GroupMemberDatabase
fun configDatabase(): ConfigDatabase
} }

View File

@ -6,7 +6,6 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider
import org.thoughtcrime.securesms.crypto.AttachmentSecret import org.thoughtcrime.securesms.crypto.AttachmentSecret
@ -132,10 +131,18 @@ object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = Storage(context,openHelper) fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage {
val storage = Storage(context,openHelper, configFactory)
threadDatabase.setUpdateListener(storage)
return storage
}
@Provides @Provides
@Singleton @Singleton
fun provideAttachmentProvider(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): MessageDataProvider = DatabaseAttachmentProvider(context, openHelper) fun provideAttachmentProvider(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): MessageDataProvider = DatabaseAttachmentProvider(context, openHelper)
@Provides
@Singleton
fun provideConfigDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): ConfigDatabase = ConfigDatabase(context, openHelper)
} }

View File

@ -1,4 +0,0 @@
package org.thoughtcrime.securesms.dependencies;
public interface InjectableType {
}

View File

@ -0,0 +1,36 @@
package org.thoughtcrime.securesms.dependencies
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.session.libsession.utilities.ConfigFactoryUpdateListener
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.database.ConfigDatabase
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SessionUtilModule {
private fun maybeUserEdSecretKey(context: Context): ByteArray? {
val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null
return edKey.secretKey.asBytes
}
@Provides
@Singleton
fun provideConfigFactory(@ApplicationContext context: Context, configDatabase: ConfigDatabase): ConfigFactory =
ConfigFactory(context, configDatabase) {
val localUserPublicKey = TextSecurePreferences.getLocalNumber(context)
val secretKey = maybeUserEdSecretKey(context)
if (localUserPublicKey == null || secretKey == null) null
else secretKey to localUserPublicKey
}.apply {
registerListener(context as ConfigFactoryUpdateListener)
}
}

View File

@ -98,7 +98,7 @@ class NewMessageFragment : Fragment() {
private fun hideLoader() { private fun hideLoader() {
binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation) super.onAnimationEnd(animation)
binding.loader.visibility = View.GONE binding.loader.visibility = View.GONE
} }

View File

@ -0,0 +1,64 @@
package org.thoughtcrime.securesms.groups
import android.content.Context
import network.loki.messenger.libsession_util.ConfigBase
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.dependencies.ConfigFactory
object ClosedGroupManager {
fun silentlyRemoveGroup(context: Context, threadId: Long, groupPublicKey: String, groupID: String, userPublicKey: String, delete: Boolean = true) {
val storage = MessagingModuleConfiguration.shared.storage
// Mark the group as inactive
storage.setActive(groupID, false)
storage.removeClosedGroupPublicKey(groupPublicKey)
// Remove the key pairs
storage.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey)
storage.removeMember(groupID, Address.fromSerialized(userPublicKey))
// Notify the PN server
PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey)
// Stop polling
ClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
storage.cancelPendingMessageSendJobs(threadId)
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
if (delete) {
storage.deleteConversation(threadId)
}
}
fun ConfigFactory.removeLegacyGroup(group: GroupRecord): Boolean {
val groups = userGroups ?: return false
if (!group.isClosedGroup) return false
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())
return groups.eraseLegacyGroup(groupPublicKey)
}
fun ConfigFactory.updateLegacyGroup(groupRecipientSettings: Recipient.RecipientSettings, group: GroupRecord) {
val groups = userGroups ?: return
if (!group.isClosedGroup) return
val storage = MessagingModuleConfiguration.shared.storage
val threadId = storage.getThreadId(group.encodedId) ?: return
val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId())
val latestKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return
val legacyInfo = groups.getOrConstructLegacyGroupInfo(groupPublicKey)
val latestMemberMap = GroupUtil.createConfigMemberMap(group.members.map(Address::serialize), group.admins.map(Address::serialize))
val toSet = legacyInfo.copy(
members = latestMemberMap,
name = group.title,
disappearingTimer = groupRecipientSettings.expireMessages.toLong(),
priority = if (storage.isPinned(threadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = latestKeyPair.privateKey.serialize()
)
groups.set(toSet)
}
}

View File

@ -16,6 +16,7 @@ import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import nl.komponents.kovenant.Promise import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.task import nl.komponents.kovenant.task
@ -28,16 +29,28 @@ import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.contacts.SelectContactsActivity import org.thoughtcrime.securesms.contacts.SelectContactsActivity
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup
import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeIn
import org.thoughtcrime.securesms.util.fadeOut import org.thoughtcrime.securesms.util.fadeOut
import java.io.IOException import java.io.IOException
import javax.inject.Inject
@AndroidEntryPoint
class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
@Inject
lateinit var groupConfigFactory: ConfigFactory
@Inject
lateinit var storage: Storage
private val originalMembers = HashSet<String>() private val originalMembers = HashSet<String>()
private val zombies = HashSet<String>() private val zombies = HashSet<String>()
private val members = HashSet<String>() private val members = HashSet<String>()
@ -289,7 +302,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
isLoading = true isLoading = true
loaderContainer.fadeIn() loaderContainer.fadeIn()
val promise: Promise<Any, Exception> = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) { val promise: Promise<Any, Exception> = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) {
MessageSender.explicitLeave(groupPublicKey!!, true) MessageSender.explicitLeave(groupPublicKey!!, false)
} else { } else {
task { task {
if (hasNameChanged) { if (hasNameChanged) {
@ -306,6 +319,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
promise.successUi { promise.successUi {
loaderContainer.fadeOut() loaderContainer.fadeOut()
isLoading = false isLoading = false
updateGroupConfig()
finish() finish()
}.failUi { exception -> }.failUi { exception ->
val message = if (exception is MessageSender.Error) exception.description else "An error occurred" val message = if (exception is MessageSender.Error) exception.description else "An error occurred"
@ -316,5 +330,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() {
} }
} }
class GroupMembers(val members: List<String>, val zombieMembers: List<String>) { } private fun updateGroupConfig() {
val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID))
?: return Log.w("Loki", "No recipient settings when trying to update group config")
val latestGroup = storage.getGroup(groupID)
?: return Log.w("Loki", "No group record when trying to update group config")
groupConfigFactory.updateLegacyGroup(latestRecipient, latestGroup)
}
class GroupMembers(val members: List<String>, val zombieMembers: List<String>)
} }

View File

@ -6,6 +6,7 @@ import android.graphics.Bitmap;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.DistributionTypes; import org.session.libsession.utilities.DistributionTypes;
import org.session.libsession.utilities.GroupUtil; import org.session.libsession.utilities.GroupUtil;
@ -16,11 +17,14 @@ import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.BitmapUtil;
import java.io.IOException;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import network.loki.messenger.libsession_util.UserGroupsConfig;
public class GroupManager { public class GroupManager {
public static long getOpenGroupThreadID(String id, @NonNull Context context) { public static long getOpenGroupThreadID(String id, @NonNull Context context) {

View File

@ -55,7 +55,7 @@ class JoinCommunityFragment : Fragment() {
fun hideLoader() { fun hideLoader() {
binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation) super.onAnimationEnd(animation)
binding.loader.visibility = View.GONE binding.loader.visibility = View.GONE
} }
@ -79,7 +79,7 @@ class JoinCommunityFragment : Fragment() {
val openGroupID = "$sanitizedServer.${openGroup.room}" val openGroupID = "$sanitizedServer.${openGroup.room}"
OpenGroupManager.add(sanitizedServer, openGroup.room, openGroup.serverPublicKey, requireContext()) OpenGroupManager.add(sanitizedServer, openGroup.room, openGroup.serverPublicKey, requireContext())
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
storage.onOpenGroupAdded(sanitizedServer) storage.onOpenGroupAdded(sanitizedServer, openGroup.room)
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, requireContext()) val threadID = GroupManager.getOpenGroupThreadID(openGroupID, requireContext())
val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())

View File

@ -9,8 +9,8 @@ import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import java.util.concurrent.Executors import java.util.concurrent.Executors
object OpenGroupManager { object OpenGroupManager {
@ -40,7 +40,13 @@ object OpenGroupManager {
if (isPolling) { return } if (isPolling) { return }
isPolling = true isPolling = true
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val servers = storage.getAllOpenGroups().values.map { it.server }.toSet() val (serverGroups, toDelete) = storage.getAllOpenGroups().values.partition { storage.getThreadId(it) != null }
toDelete.forEach { openGroup ->
Log.w("Loki", "Need to delete a group")
delete(openGroup.server, openGroup.room, MessagingModuleConfiguration.shared.context)
}
val servers = serverGroups.map { it.server }.toSet()
synchronized(pollUpdaterLock) { synchronized(pollUpdaterLock) {
servers.forEach { server -> servers.forEach { server ->
pollers[server]?.stop() // Shouldn't be necessary pollers[server]?.stop() // Shouldn't be necessary
@ -58,14 +64,14 @@ object OpenGroupManager {
} }
@WorkerThread @WorkerThread
fun add(server: String, room: String, publicKey: String, context: Context): OpenGroupApi.RoomInfo? { fun add(server: String, room: String, publicKey: String, context: Context): Pair<Long,OpenGroupApi.RoomInfo?> {
val openGroupID = "$server.$room" val openGroupID = "$server.$room"
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context)
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() val threadDB = DatabaseComponent.get(context).lokiThreadDatabase()
// Check it it's added already // Check it it's added already
val existingOpenGroup = threadDB.getOpenGroupChat(threadID) val existingOpenGroup = threadDB.getOpenGroupChat(threadID)
if (existingOpenGroup != null) { return null } if (existingOpenGroup != null) { return threadID to null }
// Clear any existing data if needed // Clear any existing data if needed
storage.removeLastDeletionServerID(room, server) storage.removeLastDeletionServerID(room, server)
storage.removeLastMessageServerID(room, server) storage.removeLastMessageServerID(room, server)
@ -86,7 +92,7 @@ object OpenGroupManager {
pollInfo = info.toPollInfo(), pollInfo = info.toPollInfo(),
createGroupIfMissingWithPublicKey = publicKey createGroupIfMissingWithPublicKey = publicKey
) )
return info return threadID to info
} }
fun restartPollerForServer(server: String) { fun restartPollerForServer(server: String) {
@ -102,23 +108,27 @@ object OpenGroupManager {
} }
} }
@WorkerThread
fun delete(server: String, room: String, context: Context) { fun delete(server: String, room: String, context: Context) {
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val configFactory = MessagingModuleConfiguration.shared.configFactory
val threadDB = DatabaseComponent.get(context).threadDatabase() val threadDB = DatabaseComponent.get(context).threadDatabase()
val openGroupID = "$server.$room" val openGroupID = "${server.removeSuffix("/")}.$room"
val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context)
val recipient = threadDB.getRecipientForThreadId(threadID) ?: return val recipient = threadDB.getRecipientForThreadId(threadID) ?: return
threadDB.setThreadArchived(threadID) threadDB.setThreadArchived(threadID)
val groupID = recipient.address.serialize() val groupID = recipient.address.serialize()
// Stop the poller if needed // Stop the poller if needed
val openGroups = storage.getAllOpenGroups().filter { it.value.server == server } val openGroups = storage.getAllOpenGroups().filter { it.value.server == server }
if (openGroups.count() == 1) { if (openGroups.isNotEmpty()) {
synchronized(pollUpdaterLock) { synchronized(pollUpdaterLock) {
val poller = pollers[server] val poller = pollers[server]
poller?.stop() poller?.stop()
pollers.remove(server) pollers.remove(server)
} }
} }
configFactory.userGroups?.eraseCommunity(server, room)
configFactory.convoVolatile?.eraseCommunity(server, room)
// Delete // Delete
storage.removeLastDeletionServerID(room, server) storage.removeLastDeletionServerID(room, server)
storage.removeLastMessageServerID(room, server) storage.removeLastMessageServerID(room, server)
@ -126,19 +136,19 @@ object OpenGroupManager {
storage.removeLastOutboxMessageId(server) storage.removeLastOutboxMessageId(server)
val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase() val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase()
lokiThreadDB.removeOpenGroupChat(threadID) lokiThreadDB.removeOpenGroupChat(threadID)
ThreadUtils.queue { storage.deleteConversation(threadID) // Must be invoked on a background thread
threadDB.deleteConversation(threadID) // Must be invoked on a background thread GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread
GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
} }
@WorkerThread
fun addOpenGroup(urlAsString: String, context: Context): OpenGroupApi.RoomInfo? { fun addOpenGroup(urlAsString: String, context: Context): OpenGroupApi.RoomInfo? {
val url = HttpUrl.parse(urlAsString) ?: return null val url = HttpUrl.parse(urlAsString) ?: return null
val server = OpenGroup.getServer(urlAsString) val server = OpenGroup.getServer(urlAsString)
val room = url.pathSegments().firstOrNull() ?: return null val room = url.pathSegments().firstOrNull() ?: return null
val publicKey = url.queryParameter("public_key") ?: return null val publicKey = url.queryParameter("public_key") ?: return null
return add(server.toString().removeSuffix("/"), room, publicKey, context) // assume migrated from calling function return add(server.toString().removeSuffix("/"), room, publicKey, context).second // assume migrated from calling function
} }
fun updateOpenGroup(openGroup: OpenGroup, context: Context) { fun updateOpenGroup(openGroup: OpenGroup, context: Context) {

View File

@ -7,10 +7,15 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.UiModeUtilities import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.getConversationUnread
import javax.inject.Inject
@AndroidEntryPoint
class ConversationOptionsBottomSheet(private val parentContext: Context) : BottomSheetDialogFragment(), View.OnClickListener { class ConversationOptionsBottomSheet(private val parentContext: Context) : BottomSheetDialogFragment(), View.OnClickListener {
private lateinit var binding: FragmentConversationBottomSheetBinding private lateinit var binding: FragmentConversationBottomSheetBinding
//FIXME AC: Supplying a threadRecord directly into the field from an activity //FIXME AC: Supplying a threadRecord directly into the field from an activity
@ -19,6 +24,8 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
// if we want to use dialog fragments properly. // if we want to use dialog fragments properly.
lateinit var thread: ThreadRecord lateinit var thread: ThreadRecord
@Inject lateinit var configFactory: ConfigFactory
var onViewDetailsTapped: (() -> Unit?)? = null var onViewDetailsTapped: (() -> Unit?)? = null
var onCopyConversationId: (() -> Unit?)? = null var onCopyConversationId: (() -> Unit?)? = null
var onPinTapped: (() -> Unit)? = null var onPinTapped: (() -> Unit)? = null
@ -77,7 +84,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted
binding.notificationsTextView.setOnClickListener(this) binding.notificationsTextView.setOnClickListener(this)
binding.deleteTextView.setOnClickListener(this) binding.deleteTextView.setOnClickListener(this)
binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true
binding.markAllAsReadTextView.setOnClickListener(this) binding.markAllAsReadTextView.setOnClickListener(this)
binding.pinTextView.isVisible = !thread.isPinned binding.pinTextView.isVisible = !thread.isPinned
binding.unpinTextView.isVisible = thread.isPinned binding.unpinTextView.isVisible = thread.isPinned

View File

@ -6,12 +6,12 @@ import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.util.AttributeSet import android.util.AttributeSet
import android.util.TypedValue import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewConversationBinding import network.loki.messenger.databinding.ViewConversationBinding
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
@ -19,12 +19,19 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.hig
import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL
import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.getAccentColor import org.thoughtcrime.securesms.util.getAccentColor
import org.thoughtcrime.securesms.util.getConversationUnread
import java.util.Locale import java.util.Locale
import javax.inject.Inject
@AndroidEntryPoint
class ConversationView : LinearLayout { class ConversationView : LinearLayout {
@Inject lateinit var configFactory: ConfigFactory
private val binding: ViewConversationBinding by lazy { ViewConversationBinding.bind(this) } private val binding: ViewConversationBinding by lazy { ViewConversationBinding.bind(this) }
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
var thread: ThreadRecord? = null var thread: ThreadRecord? = null
@ -58,7 +65,6 @@ class ConversationView : LinearLayout {
} else { } else {
ContextCompat.getDrawable(context, R.drawable.conversation_view_background) ContextCompat.getDrawable(context, R.drawable.conversation_view_background)
} }
binding.profilePictureView.root.glide = glide
val unreadCount = thread.unreadCount val unreadCount = thread.unreadCount
if (thread.recipient.isBlocked) { if (thread.recipient.isBlocked) {
binding.accentView.setBackgroundResource(R.color.destructive) binding.accentView.setBackgroundResource(R.color.destructive)
@ -71,7 +77,7 @@ class ConversationView : LinearLayout {
// This would also not trigger the disappearing message timer which may or may not be desirable // This would also not trigger the disappearing message timer which may or may not be desirable
binding.accentView.visibility = if (unreadCount > 0 && !thread.isRead) View.VISIBLE else View.INVISIBLE binding.accentView.visibility = if (unreadCount > 0 && !thread.isRead) View.VISIBLE else View.INVISIBLE
} }
val formattedUnreadCount = if (thread.isRead) { val formattedUnreadCount = if (unreadCount == 0) {
null null
} else { } else {
if (unreadCount < 10000) unreadCount.toString() else "9999+" if (unreadCount < 10000) unreadCount.toString() else "9999+"
@ -80,6 +86,7 @@ class ConversationView : LinearLayout {
val textSize = if (unreadCount < 1000) 12.0f else 10.0f val textSize = if (unreadCount < 1000) 12.0f else 10.0f
binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize)
binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead) binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead)
|| (configFactory.convoVolatile?.getConversationUnread(thread) == true)
binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize)
binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroup) binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroup)
val senderDisplayName = getUserDisplayName(thread.recipient) val senderDisplayName = getUserDisplayName(thread.recipient)
@ -117,18 +124,18 @@ class ConversationView : LinearLayout {
thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check) thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check)
else -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) else -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check)
} }
binding.profilePictureView.root.update(thread.recipient) binding.profilePictureView.update(thread.recipient)
} }
fun recycle() { fun recycle() {
binding.profilePictureView.root.recycle() binding.profilePictureView.recycle()
} }
private fun getUserDisplayName(recipient: Recipient): String? { private fun getUserDisplayName(recipient: Recipient): String? {
return if (recipient.isLocalNumber) { return if (recipient.isLocalNumber) {
context.getString(R.string.note_to_self) context.getString(R.string.note_to_self)
} else { } else {
recipient.name // Internally uses the Contact API recipient.toShortString() // Internally uses the Contact API
} }
} }
// endregion // endregion

View File

@ -1,19 +1,22 @@
package org.thoughtcrime.securesms.home package org.thoughtcrime.securesms.home
import android.Manifest
import android.app.NotificationManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.ClipData
import android.content.ClipboardManager
import android.os.Bundle import android.os.Bundle
import android.text.SpannableString import android.text.SpannableString
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -27,11 +30,14 @@ import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityHomeBinding import network.loki.messenger.databinding.ActivityHomeBinding
import network.loki.messenger.databinding.ViewMessageRequestBannerBinding import network.loki.messenger.databinding.ViewMessageRequestBannerBinding
import network.loki.messenger.libsession_util.ConfigBase
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.ProfilePictureModifiedEvent import org.session.libsession.utilities.ProfilePictureModifiedEvent
@ -41,7 +47,6 @@ import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.ThreadUtils
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.start.NewConversationFragment import org.thoughtcrime.securesms.conversation.start.NewConversationFragment
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
@ -50,8 +55,10 @@ import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter
@ -62,7 +69,10 @@ import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.onboarding.SeedActivity import org.thoughtcrime.securesms.onboarding.SeedActivity
import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.preferences.SettingsActivity
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.showMuteDialog
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.IP2Country import org.thoughtcrime.securesms.util.IP2Country
@ -80,6 +90,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
SeedReminderViewDelegate, SeedReminderViewDelegate,
GlobalSearchInputLayout.GlobalSearchInputLayoutListener { GlobalSearchInputLayout.GlobalSearchInputLayoutListener {
companion object {
const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING"
}
private lateinit var binding: ActivityHomeBinding private lateinit var binding: ActivityHomeBinding
private lateinit var glide: GlideRequests private lateinit var glide: GlideRequests
private var broadcastReceiver: BroadcastReceiver? = null private var broadcastReceiver: BroadcastReceiver? = null
@ -87,8 +102,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
@Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase
@Inject lateinit var recipientDatabase: RecipientDatabase @Inject lateinit var recipientDatabase: RecipientDatabase
@Inject lateinit var storage: Storage
@Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var groupDatabase: GroupDatabase
@Inject lateinit var textSecurePreferences: TextSecurePreferences @Inject lateinit var textSecurePreferences: TextSecurePreferences
@Inject lateinit var configFactory: ConfigFactory
private val globalSearchViewModel by viewModels<GlobalSearchViewModel>() private val globalSearchViewModel by viewModels<GlobalSearchViewModel>()
private val homeViewModel by viewModels<HomeViewModel>() private val homeViewModel by viewModels<HomeViewModel>()
@ -97,7 +114,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
get() = textSecurePreferences.getLocalNumber()!! get() = textSecurePreferences.getLocalNumber()!!
private val homeAdapter: HomeAdapter by lazy { private val homeAdapter: HomeAdapter by lazy {
HomeAdapter(context = this, listener = this) HomeAdapter(context = this, configFactory = configFactory, listener = this)
} }
private val globalSearchAdapter = GlobalSearchAdapter { model -> private val globalSearchAdapter = GlobalSearchAdapter { model ->
@ -151,22 +168,23 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// Set up Glide // Set up Glide
glide = GlideApp.with(this) glide = GlideApp.with(this)
// Set up toolbar buttons // Set up toolbar buttons
binding.profileButton.root.glide = glide binding.profileButton.setOnClickListener { openSettings() }
binding.profileButton.root.setOnClickListener { openSettings() }
binding.searchViewContainer.setOnClickListener { binding.searchViewContainer.setOnClickListener {
binding.globalSearchInputLayout.requestFocus() binding.globalSearchInputLayout.requestFocus()
} }
binding.sessionToolbar.disableClipping() binding.sessionToolbar.disableClipping()
// Set up seed reminder view // Set up seed reminder view
val hasViewedSeed = textSecurePreferences.getHasViewedSeed() lifecycleScope.launchWhenStarted {
if (!hasViewedSeed) { val hasViewedSeed = textSecurePreferences.getHasViewedSeed()
binding.seedReminderView.isVisible = true if (!hasViewedSeed) {
binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated binding.seedReminderView.isVisible = true
binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1) binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated
binding.seedReminderView.setProgress(80, false) binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1)
binding.seedReminderView.delegate = this@HomeActivity binding.seedReminderView.setProgress(80, false)
} else { binding.seedReminderView.delegate = this@HomeActivity
binding.seedReminderView.isVisible = false } else {
binding.seedReminderView.isVisible = false
}
} }
setupMessageRequestsBanner() setupMessageRequestsBanner()
// Set up recycler view // Set up recycler view
@ -176,6 +194,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
binding.recyclerView.adapter = homeAdapter binding.recyclerView.adapter = homeAdapter
binding.globalSearchRecycler.adapter = globalSearchAdapter binding.globalSearchRecycler.adapter = globalSearchAdapter
binding.configOutdatedView.setOnClickListener {
textSecurePreferences.setHasLegacyConfig(false)
updateLegacyConfigView()
}
// Set up empty state view // Set up empty state view
binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() } binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() }
IP2Country.configureIfNeeded(this@HomeActivity) IP2Country.configureIfNeeded(this@HomeActivity)
@ -192,6 +215,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
this.broadcastReceiver = broadcastReceiver this.broadcastReceiver = broadcastReceiver
LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged")) LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged"))
// subscribe to outdated config updates, this should be removed after long enough time for device migration
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
TextSecurePreferences.events.filter { it == TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG }.collect {
updateLegacyConfigView()
}
}
}
lifecycleScope.launchWhenStarted { lifecycleScope.launchWhenStarted {
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
// Double check that the long poller is up // Double check that the long poller is up
@ -212,6 +244,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
} }
} }
// monitor the global search VM query // monitor the global search VM query
launch { launch {
binding.globalSearchInputLayout.query binding.globalSearchInputLayout.query
@ -264,6 +297,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
} }
EventBus.getDefault().register(this@HomeActivity) EventBus.getDefault().register(this@HomeActivity)
if (intent.hasExtra(FROM_ONBOARDING)
&& intent.getBooleanExtra(FROM_ONBOARDING, false)
&& !(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()
) {
Permissions.with(this)
.request(Manifest.permission.POST_NOTIFICATIONS)
.execute()
}
} }
override fun onInputFocusChanged(hasFocus: Boolean) { override fun onInputFocusChanged(hasFocus: Boolean) {
@ -312,16 +353,26 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
} }
private fun updateLegacyConfigView() {
binding.configOutdatedView.isVisible = ConfigBase.isNewConfigEnabled(textSecurePreferences.hasForcedNewConfig(), SnodeAPI.nowWithOffset)
&& textSecurePreferences.getHasLegacyConfig()
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true) ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true)
if (textSecurePreferences.getLocalNumber() == null) { return; } // This can be the case after a secondary device is auto-cleared if (textSecurePreferences.getLocalNumber() == null) { return; } // This can be the case after a secondary device is auto-cleared
IdentityKeyUtil.checkUpdate(this) IdentityKeyUtil.checkUpdate(this)
binding.profileButton.root.recycle() // clear cached image before update tje profilePictureView binding.profileButton.recycle() // clear cached image before update tje profilePictureView
binding.profileButton.root.update() binding.profileButton.update()
if (textSecurePreferences.getHasViewedSeed()) { if (textSecurePreferences.getHasViewedSeed()) {
binding.seedReminderView.isVisible = false binding.seedReminderView.isVisible = false
} }
updateLegacyConfigView()
// TODO: remove this after enough updates that we can rely on ConfigBase.isNewConfigEnabled to always return true
// This will only run if we aren't using new configs, as they are schedule to sync when there are changes applied
if (textSecurePreferences.getConfigurationMessageSynced()) { if (textSecurePreferences.getConfigurationMessageSynced()) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity) ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity)
@ -388,10 +439,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
private fun updateProfileButton() { private fun updateProfileButton() {
binding.profileButton.root.publicKey = publicKey binding.profileButton.publicKey = publicKey
binding.profileButton.root.displayName = textSecurePreferences.getProfileName() binding.profileButton.displayName = textSecurePreferences.getProfileName()
binding.profileButton.root.recycle() binding.profileButton.recycle()
binding.profileButton.root.update() binding.profileButton.update()
} }
// endregion // endregion
@ -488,39 +539,37 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
private fun blockConversation(thread: ThreadRecord) { private fun blockConversation(thread: ThreadRecord) {
AlertDialog.Builder(this) showSessionDialog {
.setTitle(R.string.RecipientPreferenceActivity_block_this_contact_question) title(R.string.RecipientPreferenceActivity_block_this_contact_question)
.setMessage(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact)
.setNegativeButton(android.R.string.cancel, null) button(R.string.RecipientPreferenceActivity_block) {
.setPositiveButton(R.string.RecipientPreferenceActivity_block) { dialog, _ -> lifecycleScope.launch(Dispatchers.IO) {
lifecycleScope.launch(Dispatchers.IO) { storage.setBlocked(listOf(thread.recipient), true)
recipientDatabase.setBlocked(thread.recipient, true)
// TODO: Remove in UserConfig branch withContext(Dispatchers.Main) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity) binding.recyclerView.adapter!!.notifyDataSetChanged()
withContext(Dispatchers.Main) {
binding.recyclerView.adapter!!.notifyDataSetChanged()
dialog.dismiss()
}
} }
}.show() }
}
cancelButton()
}
} }
private fun unblockConversation(thread: ThreadRecord) { private fun unblockConversation(thread: ThreadRecord) {
AlertDialog.Builder(this) showSessionDialog {
.setTitle(R.string.RecipientPreferenceActivity_unblock_this_contact_question) title(R.string.RecipientPreferenceActivity_unblock_this_contact_question)
.setMessage(R.string.RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact) text(R.string.RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact)
.setNegativeButton(android.R.string.cancel, null) button(R.string.RecipientPreferenceActivity_unblock) {
.setPositiveButton(R.string.RecipientPreferenceActivity_unblock) { dialog, _ -> lifecycleScope.launch(Dispatchers.IO) {
lifecycleScope.launch(Dispatchers.IO) { storage.setBlocked(listOf(thread.recipient), false)
recipientDatabase.setBlocked(thread.recipient, false)
// TODO: Remove in UserConfig branch withContext(Dispatchers.Main) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity) binding.recyclerView.adapter!!.notifyDataSetChanged()
withContext(Dispatchers.Main) {
binding.recyclerView.adapter!!.notifyDataSetChanged()
dialog.dismiss()
}
} }
}.show() }
}
cancelButton()
}
} }
private fun setConversationMuted(thread: ThreadRecord, isMuted: Boolean) { private fun setConversationMuted(thread: ThreadRecord, isMuted: Boolean) {
@ -532,7 +581,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
} }
} else { } else {
MuteDialog.show(this) { until: Long -> showMuteDialog(this) { until ->
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
recipientDatabase.setMuted(thread.recipient, until) recipientDatabase.setMuted(thread.recipient, until)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -554,14 +603,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private fun setConversationPinned(threadId: Long, pinned: Boolean) { private fun setConversationPinned(threadId: Long, pinned: Boolean) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
threadDb.setPinned(threadId, pinned) storage.setPinned(threadId, pinned)
homeViewModel.tryUpdateChannel() homeViewModel.tryUpdateChannel()
} }
} }
private fun markAllAsRead(thread: ThreadRecord) { private fun markAllAsRead(thread: ThreadRecord) {
ThreadUtils.queue { ThreadUtils.queue {
threadDb.markAllAsRead(thread.threadId, thread.recipient.isOpenGroupRecipient) MessagingModuleConfiguration.shared.storage.markConversationAsRead(thread.threadId, SnodeAPI.nowWithOffset)
} }
} }
@ -578,48 +627,41 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} else { } else {
resources.getString(R.string.activity_home_delete_conversation_dialog_message) resources.getString(R.string.activity_home_delete_conversation_dialog_message)
} }
val dialog = AlertDialog.Builder(this)
dialog.setMessage(message) showSessionDialog {
dialog.setPositiveButton(R.string.yes) { _, _ -> text(message)
lifecycleScope.launch(Dispatchers.Main) { button(R.string.yes) {
val context = this@HomeActivity as Context lifecycleScope.launch(Dispatchers.Main) {
// Cancel any outstanding jobs val context = this@HomeActivity
DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID) // Cancel any outstanding jobs
// Send a leave group message if this is an active closed group DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID)
if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) { // Send a leave group message if this is an active closed group
var isClosedGroup: Boolean if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) {
var groupPublicKey: String? try {
try { GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString()
groupPublicKey = GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString() .takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup)
isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) ?.let { MessageSender.explicitLeave(it, false) }
} catch (e: IOException) { } catch (_: IOException) {
groupPublicKey = null }
isClosedGroup = false
} }
if (isClosedGroup) { // Delete the conversation
MessageSender.explicitLeave(groupPublicKey!!, false) val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID)
if (v2OpenGroup != null) {
v2OpenGroup.apply { OpenGroupManager.delete(server, room, context) }
} else {
lifecycleScope.launch(Dispatchers.IO) {
threadDb.deleteConversation(threadID)
}
} }
// Update the badge count
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
// Notify the user
val toastMessage = if (recipient.isGroupRecipient) R.string.MessageRecord_left_group else R.string.activity_home_conversation_deleted_message
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
} }
// Delete the conversation
val v2OpenGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadID)
if (v2OpenGroup != null) {
OpenGroupManager.delete(v2OpenGroup.server, v2OpenGroup.room, this@HomeActivity)
} else {
lifecycleScope.launch(Dispatchers.IO) {
threadDb.deleteConversation(threadID)
}
}
// Update the badge count
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context)
// Notify the user
val toastMessage = if (recipient.isGroupRecipient) R.string.MessageRecord_left_group else R.string.activity_home_conversation_deleted_message
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
} }
button(R.string.no)
} }
dialog.setNegativeButton(R.string.no) { _, _ ->
// Do nothing
}
dialog.create().show()
} }
private fun openSettings() { private fun openSettings() {
@ -633,17 +675,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
private fun hideMessageRequests() { private fun hideMessageRequests() {
AlertDialog.Builder(this) showSessionDialog {
.setMessage("Hide message requests?") text("Hide message requests?")
.setPositiveButton(R.string.yes) { _, _ -> button(R.string.yes) {
textSecurePreferences.setHasHiddenMessageRequests() textSecurePreferences.setHasHiddenMessageRequests()
setupMessageRequestsBanner() setupMessageRequestsBanner()
homeViewModel.tryUpdateChannel() homeViewModel.tryUpdateChannel()
} }
.setNegativeButton(R.string.no) { _, _ -> button(R.string.no)
// Do nothing }
}
.create().show()
} }
private fun showNewConversation() { private fun showNewConversation() {

View File

@ -10,10 +10,12 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID import androidx.recyclerview.widget.RecyclerView.NO_ID
import network.loki.messenger.R import network.loki.messenger.R
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
class HomeAdapter( class HomeAdapter(
private val context: Context, private val context: Context,
private val configFactory: ConfigFactory,
private val listener: ConversationClickListener private val listener: ConversationClickListener
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), ListUpdateCallback { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), ListUpdateCallback {
@ -29,7 +31,7 @@ class HomeAdapter(
get() = _data.toList() get() = _data.toList()
set(newData) { set(newData) {
val previousData = _data.toList() val previousData = _data.toList()
val diff = HomeDiffUtil(previousData, newData, context) val diff = HomeDiffUtil(previousData, newData, context, configFactory)
val diffResult = DiffUtil.calculateDiff(diff) val diffResult = DiffUtil.calculateDiff(diff)
_data = newData _data = newData
diffResult.dispatchUpdatesTo(this as ListUpdateCallback) diffResult.dispatchUpdatesTo(this as ListUpdateCallback)

View File

@ -3,11 +3,14 @@ package org.thoughtcrime.securesms.home
import android.content.Context import android.content.Context
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.getConversationUnread
class HomeDiffUtil( class HomeDiffUtil(
private val old: List<ThreadRecord>, private val old: List<ThreadRecord>,
private val new: List<ThreadRecord>, private val new: List<ThreadRecord>,
private val context: Context private val context: Context,
private val configFactory: ConfigFactory
): DiffUtil.Callback() { ): DiffUtil.Callback() {
override fun getOldListSize(): Int = old.size override fun getOldListSize(): Int = old.size
@ -42,7 +45,9 @@ class HomeDiffUtil(
oldItem.isFailed == newItem.isFailed && oldItem.isFailed == newItem.isFailed &&
oldItem.isDelivered == newItem.isDelivered && oldItem.isDelivered == newItem.isDelivered &&
oldItem.isSent == newItem.isSent && oldItem.isSent == newItem.isSent &&
oldItem.isPending == newItem.isPending oldItem.isPending == newItem.isPending &&
oldItem.lastSeen == newItem.lastSeen &&
configFactory.convoVolatile?.getConversationUnread(newItem) != true
) )
} }

View File

@ -9,9 +9,14 @@ import android.graphics.Paint
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.lifecycle.coroutineScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
import org.thoughtcrime.securesms.conversation.v2.ViewUtil
import org.thoughtcrime.securesms.util.getColorWithID import org.thoughtcrime.securesms.util.getColorWithID
import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.util.toPx
@ -29,6 +34,8 @@ class PathStatusView : View {
result result
} }
private var updateJob: Job? = null
constructor(context: Context) : super(context) { constructor(context: Context) : super(context) {
initialize() initialize()
} }
@ -87,16 +94,21 @@ class PathStatusView : View {
private fun handlePathsBuiltEvent() { update() } private fun handlePathsBuiltEvent() { update() }
private fun update() { private fun update() {
if (OnionRequestAPI.paths.isNotEmpty()) { if (updateJob?.isActive != true) { // false or null
setBackgroundResource(R.drawable.accent_dot) updateJob = ViewUtil.getActivityLifecycle(this)?.coroutineScope?.launchWhenStarted {
val hasPathsColor = context.getColor(R.color.accent_green) val paths = withContext(Dispatchers.IO) { OnionRequestAPI.paths }
mainColor = hasPathsColor if (paths.isNotEmpty()) {
sessionShadowColor = hasPathsColor setBackgroundResource(R.drawable.accent_dot)
} else { val hasPathsColor = context.getColor(R.color.accent_green)
setBackgroundResource(R.drawable.paths_building_dot) mainColor = hasPathsColor
val pathsBuildingColor = resources.getColorWithID(R.color.paths_building, context.theme) sessionShadowColor = hasPathsColor
mainColor = pathsBuildingColor } else {
sessionShadowColor = pathsBuildingColor setBackgroundResource(R.drawable.paths_building_dot)
val pathsBuildingColor = resources.getColorWithID(R.color.paths_building, context.theme)
mainColor = pathsBuildingColor
sessionShadowColor = pathsBuildingColor
}
}
} }
} }

View File

@ -25,9 +25,7 @@ import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.UiModeUtilities
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -55,12 +53,12 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
val recipient = Recipient.from(requireContext(), Address.fromSerialized(publicKey), false) val recipient = Recipient.from(requireContext(), Address.fromSerialized(publicKey), false)
val threadRecipient = threadDb.getRecipientForThreadId(threadID) ?: return dismiss() val threadRecipient = threadDb.getRecipientForThreadId(threadID) ?: return dismiss()
with(binding) { with(binding) {
profilePictureView.root.publicKey = publicKey profilePictureView.publicKey = publicKey
profilePictureView.root.glide = GlideApp.with(this@UserDetailsBottomSheet) profilePictureView.isLarge = true
profilePictureView.root.isLarge = true profilePictureView.update(recipient)
profilePictureView.root.update(recipient)
nameTextViewContainer.visibility = View.VISIBLE nameTextViewContainer.visibility = View.VISIBLE
nameTextViewContainer.setOnClickListener { nameTextViewContainer.setOnClickListener {
if (recipient.isOpenGroupInboxRecipient || recipient.isOpenGroupOutboxRecipient) return@setOnClickListener
nameTextViewContainer.visibility = View.INVISIBLE nameTextViewContainer.visibility = View.INVISIBLE
nameEditTextContainer.visibility = View.VISIBLE nameEditTextContainer.visibility = View.VISIBLE
nicknameEditText.text = null nicknameEditText.text = null
@ -87,8 +85,14 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
} }
nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally
publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient && !threadRecipient.isOpenGroupInboxRecipient nameEditIcon.isVisible = threadRecipient.isContactRecipient
messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey) == IdPrefix.BLINDED && !threadRecipient.isOpenGroupInboxRecipient
&& !threadRecipient.isOpenGroupOutboxRecipient
publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient
&& !threadRecipient.isOpenGroupInboxRecipient
&& !threadRecipient.isOpenGroupOutboxRecipient
messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey)?.isBlinded() == true
publicKeyTextView.text = publicKey publicKeyTextView.text = publicKey
publicKeyTextView.setOnLongClickListener { publicKeyTextView.setOnLongClickListener {
val clipboard = val clipboard =
@ -130,10 +134,10 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
newNickName = nicknameEditText.text.toString() newNickName = nicknameEditText.text.toString()
} }
val publicKey = recipient.address.serialize() val publicKey = recipient.address.serialize()
val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase() val storage = MessagingModuleConfiguration.shared.storage
val contact = contactDB.getContactWithSessionID(publicKey) ?: Contact(publicKey) val contact = storage.getContactWithSessionID(publicKey) ?: Contact(publicKey)
contact.nickname = newNickName contact.nickname = newNickName
contactDB.setContact(contact) storage.setContact(contact)
nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally
} }

View File

@ -83,22 +83,20 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
override fun onViewRecycled(holder: RecyclerView.ViewHolder) { override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if (holder is ContentView) { if (holder is ContentView) {
holder.binding.searchResultProfilePicture.root.recycle() holder.binding.searchResultProfilePicture.recycle()
} }
} }
class ContentView(view: View, private val modelCallback: (Model) -> Unit) : RecyclerView.ViewHolder(view) { class ContentView(view: View, private val modelCallback: (Model) -> Unit) : RecyclerView.ViewHolder(view) {
val binding = ViewGlobalSearchResultBinding.bind(view).apply { val binding = ViewGlobalSearchResultBinding.bind(view)
searchResultProfilePicture.root.glide = GlideApp.with(root)
}
fun bindPayload(newQuery: String, model: Model) { fun bindPayload(newQuery: String, model: Model) {
bindQuery(newQuery, model) bindQuery(newQuery, model)
} }
fun bind(query: String, model: Model) { fun bind(query: String, model: Model) {
binding.searchResultProfilePicture.root.recycle() binding.searchResultProfilePicture.recycle()
when (model) { when (model) {
is Model.GroupConversation -> bindModel(query, model) is Model.GroupConversation -> bindModel(query, model)
is Model.Contact -> bindModel(query, model) is Model.Contact -> bindModel(query, model)

View File

@ -12,6 +12,7 @@ import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
@ -76,6 +77,8 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
} }
binding.searchResultSubtitle.text = getHighlight(query, membersString) binding.searchResultSubtitle.text = getHighlight(query, membersString)
} }
is Header, // do nothing for header
is SavedMessages -> Unit // do nothing for saved messages (displays note to self)
} }
} }
@ -84,12 +87,12 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? {
} }
fun ContentView.bindModel(query: String?, model: GroupConversation) { fun ContentView.bindModel(query: String?, model: GroupConversation) {
binding.searchResultProfilePicture.root.isVisible = true binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false binding.searchResultSavedMessages.isVisible = false
binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup
binding.searchResultTimestamp.isVisible = false binding.searchResultTimestamp.isVisible = false
val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false) val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false)
binding.searchResultProfilePicture.root.update(threadRecipient) binding.searchResultProfilePicture.update(threadRecipient)
val nameString = model.groupRecord.title val nameString = model.groupRecord.title
binding.searchResultTitle.text = getHighlight(query, nameString) binding.searchResultTitle.text = getHighlight(query, nameString)
@ -105,14 +108,14 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) {
} }
fun ContentView.bindModel(query: String?, model: ContactModel) { fun ContentView.bindModel(query: String?, model: ContactModel) {
binding.searchResultProfilePicture.root.isVisible = true binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false binding.searchResultSavedMessages.isVisible = false
binding.searchResultSubtitle.isVisible = false binding.searchResultSubtitle.isVisible = false
binding.searchResultTimestamp.isVisible = false binding.searchResultTimestamp.isVisible = false
binding.searchResultSubtitle.text = null binding.searchResultSubtitle.text = null
val recipient = val recipient =
Recipient.from(binding.root.context, Address.fromSerialized(model.contact.sessionID), false) Recipient.from(binding.root.context, Address.fromSerialized(model.contact.sessionID), false)
binding.searchResultProfilePicture.root.update(recipient) binding.searchResultProfilePicture.update(recipient)
val nameString = model.contact.getSearchName() val nameString = model.contact.getSearchName()
binding.searchResultTitle.text = getHighlight(query, nameString) binding.searchResultTitle.text = getHighlight(query, nameString)
} }
@ -121,12 +124,12 @@ fun ContentView.bindModel(model: SavedMessages) {
binding.searchResultSubtitle.isVisible = false binding.searchResultSubtitle.isVisible = false
binding.searchResultTimestamp.isVisible = false binding.searchResultTimestamp.isVisible = false
binding.searchResultTitle.setText(R.string.note_to_self) binding.searchResultTitle.setText(R.string.note_to_self)
binding.searchResultProfilePicture.root.isVisible = false binding.searchResultProfilePicture.isVisible = false
binding.searchResultSavedMessages.isVisible = true binding.searchResultSavedMessages.isVisible = true
} }
fun ContentView.bindModel(query: String?, model: Message) { fun ContentView.bindModel(query: String?, model: Message) {
binding.searchResultProfilePicture.root.isVisible = true binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false binding.searchResultSavedMessages.isVisible = false
binding.searchResultTimestamp.isVisible = true binding.searchResultTimestamp.isVisible = true
// val hasUnreads = model.unread > 0 // val hasUnreads = model.unread > 0
@ -135,7 +138,7 @@ fun ContentView.bindModel(query: String?, model: Message) {
// binding.unreadCountTextView.text = model.unread.toString() // binding.unreadCountTextView.text = model.unread.toString()
// } // }
binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.sentTimestampMs)
binding.searchResultProfilePicture.root.update(model.messageResult.conversationRecipient) binding.searchResultProfilePicture.update(model.messageResult.conversationRecipient)
val textSpannable = SpannableStringBuilder() val textSpannable = SpannableStringBuilder()
if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) { if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) {
// group chat, bind // group chat, bind

View File

@ -154,7 +154,7 @@ class KeyboardPageSearchView @JvmOverloads constructor(
.setDuration(REVEAL_DURATION) .setDuration(REVEAL_DURATION)
.alpha(0f) .alpha(0f)
.setListener(object : AnimationCompleteListener() { .setListener(object : AnimationCompleteListener() {
override fun onAnimationEnd(animation: Animator?) { override fun onAnimationEnd(animation: Animator) {
visibility = INVISIBLE visibility = INVISIBLE
} }
}) })

View File

@ -34,7 +34,6 @@ class MessageRequestView : LinearLayout {
// region Updating // region Updating
fun bind(thread: ThreadRecord, glide: GlideRequests) { fun bind(thread: ThreadRecord, glide: GlideRequests) {
this.thread = thread this.thread = thread
binding.profilePictureView.root.glide = glide
val senderDisplayName = getUserDisplayName(thread.recipient) val senderDisplayName = getUserDisplayName(thread.recipient)
?: thread.recipient.address.toString() ?: thread.recipient.address.toString()
binding.displayNameTextView.text = senderDisplayName binding.displayNameTextView.text = senderDisplayName
@ -44,12 +43,12 @@ class MessageRequestView : LinearLayout {
binding.snippetTextView.text = snippet binding.snippetTextView.text = snippet
post { post {
binding.profilePictureView.root.update(thread.recipient) binding.profilePictureView.update(thread.recipient)
} }
} }
fun recycle() { fun recycle() {
binding.profilePictureView.root.recycle() binding.profilePictureView.recycle()
} }
private fun getUserDisplayName(recipient: Recipient): String? { private fun getUserDisplayName(recipient: Recipient): String? {

View File

@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.messagerequests package org.thoughtcrime.securesms.messagerequests
import android.app.AlertDialog
import android.content.Intent import android.content.Intent
import android.database.Cursor import android.database.Cursor
import android.os.Bundle import android.os.Bundle
@ -20,6 +19,7 @@ import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import javax.inject.Inject import javax.inject.Inject
@ -49,7 +49,7 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
adapter.glide = glide adapter.glide = glide
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.clearAllMessageRequestsButton.setOnClickListener { deleteAllAndBlock() } binding.clearAllMessageRequestsButton.setOnClickListener { deleteAll() }
} }
override fun onResume() { override fun onResume() {
@ -77,34 +77,34 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
} }
override fun onBlockConversationClick(thread: ThreadRecord) { override fun onBlockConversationClick(thread: ThreadRecord) {
val dialog = AlertDialog.Builder(this) fun doBlock() {
dialog.setTitle(R.string.RecipientPreferenceActivity_block_this_contact_question) viewModel.blockMessageRequest(thread)
.setMessage(R.string.message_requests_block_message) LoaderManager.getInstance(this).restartLoader(0, null, this)
.setPositiveButton(R.string.recipient_preferences__block) { _, _ -> }
viewModel.blockMessageRequest(thread)
LoaderManager.getInstance(this).restartLoader(0, null, this) showSessionDialog {
} title(R.string.RecipientPreferenceActivity_block_this_contact_question)
.setNegativeButton(R.string.no) { _, _ -> text(R.string.message_requests_block_message)
// Do nothing button(R.string.recipient_preferences__block) { doBlock() }
} button(R.string.no)
dialog.create().show() }
} }
override fun onDeleteConversationClick(thread: ThreadRecord) { override fun onDeleteConversationClick(thread: ThreadRecord) {
val dialog = AlertDialog.Builder(this) fun doDecline() {
dialog.setTitle(R.string.decline) viewModel.deleteMessageRequest(thread)
.setMessage(resources.getString(R.string.message_requests_decline_message)) LoaderManager.getInstance(this).restartLoader(0, null, this)
.setPositiveButton(R.string.decline) { _,_ -> lifecycleScope.launch(Dispatchers.IO) {
viewModel.deleteMessageRequest(thread) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity)
LoaderManager.getInstance(this).restartLoader(0, null, this)
lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity)
}
} }
.setNegativeButton(R.string.no) { _, _ -> }
// Do nothing
} showSessionDialog {
dialog.create().show() title(R.string.decline)
text(resources.getString(R.string.message_requests_decline_message))
button(R.string.decline) { doDecline() }
button(R.string.no)
}
} }
private fun updateEmptyState() { private fun updateEmptyState() {
@ -113,19 +113,19 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat
binding.clearAllMessageRequestsButton.isVisible = threadCount != 0 binding.clearAllMessageRequestsButton.isVisible = threadCount != 0
} }
private fun deleteAllAndBlock() { private fun deleteAll() {
val dialog = AlertDialog.Builder(this) fun doDeleteAllAndBlock() {
dialog.setMessage(resources.getString(R.string.message_requests_clear_all_message)) viewModel.clearAllMessageRequests(false)
dialog.setPositiveButton(R.string.yes) { _, _ ->
viewModel.clearAllMessageRequests()
LoaderManager.getInstance(this).restartLoader(0, null, this) LoaderManager.getInstance(this).restartLoader(0, null, this)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity)
} }
} }
dialog.setNegativeButton(R.string.no) { _, _ ->
// Do nothing showSessionDialog {
text(resources.getString(R.string.message_requests_clear_all_message))
button(R.string.yes) { doDeleteAllAndBlock() }
button(R.string.no)
} }
dialog.create().show()
} }
} }

View File

@ -48,6 +48,7 @@ class MessageRequestsAdapter(
private fun showPopupMenu(view: MessageRequestView) { private fun showPopupMenu(view: MessageRequestView) {
val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view) val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view)
popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu) popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu)
popupMenu.menu.findItem(R.id.menu_block_message_request)?.isVisible = !view.thread!!.recipient.isOpenGroupInboxRecipient
popupMenu.setOnMenuItemClickListener { menuItem -> popupMenu.setOnMenuItemClickListener { menuItem ->
if (menuItem.itemId == R.id.menu_delete_message_request) { if (menuItem.itemId == R.id.menu_delete_message_request) {
listener.onDeleteConversationClick(view.thread!!) listener.onDeleteConversationClick(view.thread!!)

View File

@ -25,8 +25,8 @@ class MessageRequestsViewModel @Inject constructor(
repository.deleteMessageRequest(thread) repository.deleteMessageRequest(thread)
} }
fun clearAllMessageRequests() = viewModelScope.launch { fun clearAllMessageRequests(block: Boolean) = viewModelScope.launch {
repository.clearAllMessageRequests() repository.clearAllMessageRequests(block)
} }
} }

View File

@ -60,7 +60,6 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilit
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities; import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities;
import org.thoughtcrime.securesms.crypto.KeyPairUtilities; import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
import org.thoughtcrime.securesms.database.LokiThreadDatabase; import org.thoughtcrime.securesms.database.LokiThreadDatabase;
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase;
@ -160,8 +159,9 @@ public class DefaultMessageNotifier implements MessageNotifier {
executor.cancel(); executor.cancel();
} }
private void cancelActiveNotifications(@NonNull Context context) { private boolean cancelActiveNotifications(@NonNull Context context) {
NotificationManager notifications = ServiceUtil.getNotificationManager(context); NotificationManager notifications = ServiceUtil.getNotificationManager(context);
boolean hasNotifications = notifications.getActiveNotifications().length > 0;
notifications.cancel(SUMMARY_NOTIFICATION_ID); notifications.cancel(SUMMARY_NOTIFICATION_ID);
try { try {
@ -175,6 +175,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
Log.w(TAG, e); Log.w(TAG, e);
notifications.cancelAll(); notifications.cancelAll();
} }
return hasNotifications;
} }
private void cancelOrphanedNotifications(@NonNull Context context, NotificationState notificationState) { private void cancelOrphanedNotifications(@NonNull Context context, NotificationState notificationState) {
@ -240,10 +241,6 @@ public class DefaultMessageNotifier implements MessageNotifier {
!(recipient.isApproved() || threads.getLastSeenAndHasSent(threadId).second())) { !(recipient.isApproved() || threads.getLastSeenAndHasSent(threadId).second())) {
TextSecurePreferences.removeHasHiddenMessageRequests(context); TextSecurePreferences.removeHasHiddenMessageRequests(context);
} }
if (isVisible && recipient != null) {
List<MarkedMessageInfo> messageIds = threads.setRead(threadId, false);
if (SessionMetaProtocol.shouldSendReadReceipt(recipient)) { MarkReadReceiver.process(context, messageIds); }
}
if (!TextSecurePreferences.isNotificationsEnabled(context) || if (!TextSecurePreferences.isNotificationsEnabled(context) ||
(recipient != null && recipient.isMuted())) (recipient != null && recipient.isMuted()))
@ -251,11 +248,21 @@ public class DefaultMessageNotifier implements MessageNotifier {
return; return;
} }
if (!isVisible && !homeScreenVisible) { if ((!isVisible && !homeScreenVisible) || hasExistingNotifications(context)) {
updateNotification(context, signal, 0); updateNotification(context, signal, 0);
} }
} }
private boolean hasExistingNotifications(Context context) {
NotificationManager notifications = ServiceUtil.getNotificationManager(context);
try {
StatusBarNotification[] activeNotifications = notifications.getActiveNotifications();
return activeNotifications.length > 0;
} catch (Exception e) {
return false;
}
}
@Override @Override
public void updateNotification(@NonNull Context context, boolean signal, int reminderCount) public void updateNotification(@NonNull Context context, boolean signal, int reminderCount)
{ {
@ -267,8 +274,8 @@ public class DefaultMessageNotifier implements MessageNotifier {
if ((telcoCursor == null || telcoCursor.isAfterLast()) || !TextSecurePreferences.hasSeenWelcomeScreen(context)) if ((telcoCursor == null || telcoCursor.isAfterLast()) || !TextSecurePreferences.hasSeenWelcomeScreen(context))
{ {
cancelActiveNotifications(context);
updateBadge(context, 0); updateBadge(context, 0);
cancelActiveNotifications(context);
clearReminder(context); clearReminder(context);
return; return;
} }

View File

@ -12,6 +12,8 @@ import androidx.core.app.NotificationManagerCompat;
import com.annimon.stream.Collectors; import com.annimon.stream.Collectors;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import org.session.libsession.database.StorageProtocol;
import org.session.libsession.messaging.MessagingModuleConfiguration;
import org.session.libsession.messaging.messages.control.ReadReceipt; import org.session.libsession.messaging.messages.control.ReadReceipt;
import org.session.libsession.messaging.sending_receiving.MessageSender; import org.session.libsession.messaging.sending_receiving.MessageSender;
import org.session.libsession.snode.SnodeAPI; import org.session.libsession.snode.SnodeAPI;
@ -27,7 +29,6 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.SessionMetaProtocol; import org.thoughtcrime.securesms.util.SessionMetaProtocol;
import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -52,18 +53,12 @@ public class MarkReadReceiver extends BroadcastReceiver {
new AsyncTask<Void, Void, Void>() { new AsyncTask<Void, Void, Void>() {
@Override @Override
protected Void doInBackground(Void... params) { protected Void doInBackground(Void... params) {
List<MarkedMessageInfo> messageIdsCollection = new LinkedList<>(); long currentTime = SnodeAPI.getNowWithOffset();
for (long threadId : threadIds) { for (long threadId : threadIds) {
Log.i(TAG, "Marking as read: " + threadId); Log.i(TAG, "Marking as read: " + threadId);
List<MarkedMessageInfo> messageIds = DatabaseComponent.get(context).threadDatabase().setRead(threadId, true); StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage();
messageIdsCollection.addAll(messageIds); storage.markConversationAsRead(threadId,currentTime, true);
} }
process(context, messageIdsCollection);
ApplicationContext.getInstance(context).messageNotifier.updateNotification(context);
return null; return null;
} }
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);

View File

@ -15,6 +15,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentPagerAdapter import androidx.fragment.app.FragmentPagerAdapter
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
@ -34,12 +35,19 @@ import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.crypto.MnemonicUtilities import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
@AndroidEntryPoint
class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate {
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivityLinkDeviceBinding private lateinit var binding: ActivityLinkDeviceBinding
internal val database: LokiAPIDatabaseProtocol internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage get() = SnodeModule.shared.storage
@ -112,6 +120,7 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel
val keyPairGenerationResult = KeyPairUtilities.generate(seed) val keyPairGenerationResult = KeyPairUtilities.generate(seed)
val x25519KeyPair = keyPairGenerationResult.x25519KeyPair val x25519KeyPair = keyPairGenerationResult.x25519KeyPair
KeyPairUtilities.store(this@LinkDeviceActivity, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair) KeyPairUtilities.store(this@LinkDeviceActivity, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false) val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this@LinkDeviceActivity, registrationID) TextSecurePreferences.setLocalRegistrationId(this@LinkDeviceActivity, registrationID)
@ -124,9 +133,8 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel
.setAction(R.string.registration_activity__skip) { register(true) } .setAction(R.string.registration_activity__skip) { register(true) }
val skipJob = launch { val skipJob = launch {
delay(30_000L) delay(15_000L)
snackBar.show() snackBar.show()
// show a dialog or something saying do you want to skip this bit?
} }
// start polling and wait for updated message // start polling and wait for updated message
ApplicationContext.getInstance(this@LinkDeviceActivity).apply { ApplicationContext.getInstance(this@LinkDeviceActivity).apply {

View File

@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.onboarding
import android.animation.ArgbEvaluator import android.animation.ArgbEvaluator
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.app.AlertDialog
import android.content.Intent import android.content.Intent
import android.graphics.drawable.TransitionDrawable import android.graphics.drawable.TransitionDrawable
import android.net.Uri import android.net.Uri
@ -20,6 +19,7 @@ import org.session.libsession.utilities.ThemeUtil
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.home.HomeActivity import org.thoughtcrime.securesms.home.HomeActivity
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.GlowViewUtilities
import org.thoughtcrime.securesms.util.PNModeView import org.thoughtcrime.securesms.util.PNModeView
import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.disableClipping
@ -151,18 +151,20 @@ class PNModeActivity : BaseActionBarActivity() {
private fun register() { private fun register() {
if (selectedOptionView == null) { if (selectedOptionView == null) {
val dialog = AlertDialog.Builder(this) showSessionDialog {
dialog.setTitle(R.string.activity_pn_mode_no_option_picked_dialog_title) title(R.string.activity_pn_mode_no_option_picked_dialog_title)
dialog.setPositiveButton(R.string.ok) { _, _ -> } button(R.string.ok)
dialog.create().show() }
return return
} }
TextSecurePreferences.setIsUsingFCM(this, (selectedOptionView == binding.fcmOptionView)) TextSecurePreferences.setIsUsingFCM(this, (selectedOptionView == binding.fcmOptionView))
val application = ApplicationContext.getInstance(this) val application = ApplicationContext.getInstance(this)
application.startPollingIfNeeded() application.startPollingIfNeeded()
application.registerForFCMIfNeeded(true) application.registerForFCMIfNeeded(true)
val intent = Intent(this, HomeActivity::class.java) val intent = Intent(this, HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
intent.putExtra(HomeActivity.FROM_ONBOARDING, true)
show(intent) show(intent)
} }
// endregion // endregion

View File

@ -11,6 +11,7 @@ import android.text.style.ClickableSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.view.View import android.view.View
import android.widget.Toast import android.widget.Toast
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityRecoveryPhraseRestoreBinding import network.loki.messenger.databinding.ActivityRecoveryPhraseRestoreBinding
import org.session.libsession.snode.SnodeModule import org.session.libsession.snode.SnodeModule
@ -23,10 +24,17 @@ import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.crypto.MnemonicUtilities import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
@AndroidEntryPoint
class RecoveryPhraseRestoreActivity : BaseActionBarActivity() { class RecoveryPhraseRestoreActivity : BaseActionBarActivity() {
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivityRecoveryPhraseRestoreBinding private lateinit var binding: ActivityRecoveryPhraseRestoreBinding
internal val database: LokiAPIDatabaseProtocol internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage get() = SnodeModule.shared.storage
@ -81,6 +89,7 @@ class RecoveryPhraseRestoreActivity : BaseActionBarActivity() {
val keyPairGenerationResult = KeyPairUtilities.generate(seed) val keyPairGenerationResult = KeyPairUtilities.generate(seed)
val x25519KeyPair = keyPairGenerationResult.x25519KeyPair val x25519KeyPair = keyPairGenerationResult.x25519KeyPair
KeyPairUtilities.store(this, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair) KeyPairUtilities.store(this, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false) val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this, registrationID) TextSecurePreferences.setLocalRegistrationId(this, registrationID)

View File

@ -16,6 +16,7 @@ import android.text.style.StyleSpan
import android.view.View import android.view.View
import android.widget.Toast import android.widget.Toast
import com.goterl.lazysodium.utils.KeyPair import com.goterl.lazysodium.utils.KeyPair
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityRegisterBinding import network.loki.messenger.databinding.ActivityRegisterBinding
import org.session.libsession.snode.SnodeModule import org.session.libsession.snode.SnodeModule
@ -26,10 +27,17 @@ import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
@AndroidEntryPoint
class RegisterActivity : BaseActionBarActivity() { class RegisterActivity : BaseActionBarActivity() {
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivityRegisterBinding private lateinit var binding: ActivityRegisterBinding
internal val database: LokiAPIDatabaseProtocol internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage get() = SnodeModule.shared.storage
@ -119,6 +127,7 @@ class RegisterActivity : BaseActionBarActivity() {
database.clearReceivedMessageHashValues() database.clearReceivedMessageHashValues()
KeyPairUtilities.store(this, seed!!, ed25519KeyPair!!, x25519KeyPair!!) KeyPairUtilities.store(this, seed!!, ed25519KeyPair!!, x25519KeyPair!!)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false) val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this, registrationID) TextSecurePreferences.setLocalRegistrationId(this, registrationID)

View File

@ -162,15 +162,13 @@ public class Permissions {
request.onResult(requestedPermissions, grantResults, new boolean[requestedPermissions.length]); request.onResult(requestedPermissions, grantResults, new boolean[requestedPermissions.length]);
} }
@SuppressWarnings("ConstantConditions")
private void executePermissionsRequestWithRationale(PermissionsRequest request) { private void executePermissionsRequestWithRationale(PermissionsRequest request) {
AlertDialog dialog = RationaleDialog.createFor(permissionObject.getContext(), rationaleDialogMessage, rationalDialogHeader) RationaleDialog.show(
.setPositiveButton(R.string.Permissions_continue, (d, which) -> executePermissionsRequest(request)) permissionObject.getContext(),
.setNegativeButton(R.string.Permissions_not_now, (d, which) -> executeNoPermissionsRequest(request)) rationaleDialogMessage,
.show(); () -> executePermissionsRequest(request),
dialog.getWindow().setLayout((int)(permissionObject.getWindowWidth() * .75), ViewGroup.LayoutParams.WRAP_CONTENT); () -> executeNoPermissionsRequest(request),
Button positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE); rationalDialogHeader);
positiveButton.setContentDescription("Continue");
} }
private void executePermissionsRequest(PermissionsRequest request) { private void executePermissionsRequest(PermissionsRequest request) {
@ -257,7 +255,7 @@ public class Permissions {
resultListener.onResult(permissions, grantResults, shouldShowRationaleDialog); resultListener.onResult(permissions, grantResults, shouldShowRationaleDialog);
} }
private static Intent getApplicationSettingsIntent(@NonNull Context context) { static Intent getApplicationSettingsIntent(@NonNull Context context) {
Intent intent = new Intent(); Intent intent = new Intent();
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
Uri uri = Uri.fromParts("package", context.getPackageName(), null); Uri uri = Uri.fromParts("package", context.getPackageName(), null);
@ -354,20 +352,8 @@ public class Permissions {
@Override @Override
public void run() { public void run() {
Context context = this.context.get(); Context context = this.context.get();
if (context == null) return;
if (context != null) { SettingsDialog.show(context, message);
AlertDialog alertDialog = new AlertDialog.Builder(context, R.style.ThemeOverlay_Session_AlertDialog)
.setTitle(R.string.Permissions_permission_required)
.setMessage(message)
.setPositiveButton(R.string.Permissions_continue, (dialog, which) -> context.startActivity(getApplicationSettingsIntent(context)))
.setNegativeButton(android.R.string.cancel, null)
.create();
Button positiveButton = alertDialog.getButton(DialogInterface.BUTTON_POSITIVE);
if (positiveButton != null) {
positiveButton.setContentDescription(context.getString(R.string.AccessibilityId_continue));
}
alertDialog.show();
}
} }
} }
} }

View File

@ -1,56 +0,0 @@
package org.thoughtcrime.securesms.permissions;
import android.app.AlertDialog;
import android.content.Context;
import android.graphics.Color;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.LinearLayout.LayoutParams;
import android.widget.TextView;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import org.session.libsession.utilities.ViewUtil;
import network.loki.messenger.R;
public class RationaleDialog {
public static AlertDialog.Builder createFor(@NonNull Context context, @NonNull String message, @DrawableRes int... drawables) {
View view = LayoutInflater.from(context).inflate(R.layout.permissions_rationale_dialog, null);
view.setClipToOutline(true);
ViewGroup header = view.findViewById(R.id.header_container);
TextView text = view.findViewById(R.id.message);
for (int i=0;i<drawables.length;i++) {
ImageView imageView = new ImageView(context);
imageView.setImageDrawable(context.getResources().getDrawable(drawables[i]));
imageView.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT));
header.addView(imageView);
if (i != drawables.length - 1) {
TextView plus = new TextView(context);
plus.setText("+");
plus.setTextSize(TypedValue.COMPLEX_UNIT_SP, 40);
plus.setTextColor(Color.WHITE);
LayoutParams layoutParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
layoutParams.setMargins(ViewUtil.dpToPx(context, 20), 0, ViewUtil.dpToPx(context, 20), 0);
plus.setLayoutParams(layoutParams);
header.addView(plus);
}
}
text.setText(message);
return new AlertDialog.Builder(context, R.style.ThemeOverlay_Session_AlertDialog).setView(view);
}
}

View File

@ -0,0 +1,60 @@
package org.thoughtcrime.securesms.permissions
import android.content.Context
import android.graphics.Color
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.res.ResourcesCompat
import network.loki.messenger.R
import org.session.libsession.utilities.ViewUtil
import org.thoughtcrime.securesms.showSessionDialog
object RationaleDialog {
@JvmStatic
fun show(
context: Context,
message: String,
onPositive: Runnable,
onNegative: Runnable,
@DrawableRes vararg drawables: Int
): AlertDialog {
val view = LayoutInflater.from(context).inflate(R.layout.permissions_rationale_dialog, null)
.apply { clipToOutline = true }
val header = view.findViewById<ViewGroup>(R.id.header_container)
view.findViewById<TextView>(R.id.message).text = message
fun addIcon(id: Int) {
ImageView(context).apply {
setImageDrawable(ResourcesCompat.getDrawable(context.resources, id, context.theme))
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
}.also(header::addView)
}
fun addPlus() {
TextView(context).apply {
text = "+"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 40f)
setTextColor(Color.WHITE)
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
ViewUtil.dpToPx(context, 20).let { setMargins(it, 0, it, 0) }
}
}.also(header::addView)
}
drawables.firstOrNull()?.let(::addIcon)
drawables.drop(1).forEach { addPlus(); addIcon(it) }
return context.showSessionDialog {
view(view)
button(R.string.Permissions_continue) { onPositive.run() }
button(R.string.Permissions_not_now) { onNegative.run() }
}
}
}

View File

@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.permissions
import android.content.Context
import network.loki.messenger.R
import org.thoughtcrime.securesms.showSessionDialog
class SettingsDialog {
companion object {
@JvmStatic
fun show(context: Context, message: String) {
context.showSessionDialog {
title(R.string.Permissions_permission_required)
text(message)
button(R.string.Permissions_continue, R.string.AccessibilityId_continue) {
context.startActivity(Permissions.getApplicationSettingsIntent(context))
}
cancelButton()
}
}
}
}

View File

@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.preferences package org.thoughtcrime.securesms.preferences
import android.app.AlertDialog
import android.os.Bundle import android.os.Bundle
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.view.isVisible import androidx.core.view.isVisible
@ -8,6 +7,7 @@ import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityBlockedContactsBinding import network.loki.messenger.databinding.ActivityBlockedContactsBinding
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.showSessionDialog
@AndroidEntryPoint @AndroidEntryPoint
class BlockedContactsActivity: PassphraseRequiredActionBarActivity() { class BlockedContactsActivity: PassphraseRequiredActionBarActivity() {
@ -19,17 +19,12 @@ class BlockedContactsActivity: PassphraseRequiredActionBarActivity() {
val adapter: BlockedContactsAdapter by lazy { BlockedContactsAdapter(viewModel) } val adapter: BlockedContactsAdapter by lazy { BlockedContactsAdapter(viewModel) }
fun unblock() { fun unblock() {
// show dialog showSessionDialog {
val title = viewModel.getTitle(this) title(viewModel.getTitle(this@BlockedContactsActivity))
text(viewModel.getMessage(this@BlockedContactsActivity))
val message = viewModel.getMessage(this) button(R.string.continue_2) { viewModel.unblock() }
cancelButton()
AlertDialog.Builder(this) }
.setTitle(title)
.setMessage(message)
.setPositiveButton(R.string.continue_2) { _, _ -> viewModel.unblock(this@BlockedContactsActivity) }
.setNegativeButton(R.string.cancel) { _, _ -> }
.show()
} }
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
@ -51,4 +46,3 @@ class BlockedContactsActivity: PassphraseRequiredActionBarActivity() {
} }
} }

View File

@ -38,7 +38,7 @@ class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdap
override fun onViewRecycled(holder: ViewHolder) { override fun onViewRecycled(holder: ViewHolder) {
super.onViewRecycled(holder) super.onViewRecycled(holder)
holder.binding.profilePictureView.root.recycle() holder.binding.profilePictureView.recycle()
} }
class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
@ -48,8 +48,7 @@ class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdap
fun bind(selectable: SelectableRecipient, toggle: (SelectableRecipient) -> Unit) { fun bind(selectable: SelectableRecipient, toggle: (SelectableRecipient) -> Unit) {
binding.recipientName.text = selectable.item.name binding.recipientName.text = selectable.item.name
with (binding.profilePictureView.root) { with (binding.profilePictureView) {
glide = this@ViewHolder.glide
update(selectable.item) update(selectable.item)
} }
binding.root.setOnClickListener { toggle(selectable) } binding.root.setOnClickListener { toggle(selectable) }

View File

@ -1,9 +0,0 @@
package org.thoughtcrime.securesms.preferences
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
class BlockedContactsLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs)

View File

@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.preferences
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View
import androidx.preference.PreferenceCategory import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceViewHolder import androidx.preference.PreferenceViewHolder
@ -16,8 +15,7 @@ class BlockedContactsPreference @JvmOverloads constructor(
super.onBindViewHolder(holder) super.onBindViewHolder(holder)
holder.itemView.setOnClickListener { holder.itemView.setOnClickListener {
val intent = Intent(context, BlockedContactsActivity::class.java) Intent(context, BlockedContactsActivity::class.java).let(context::startActivity)
context.startActivity(intent)
} }
} }
} }

View File

@ -63,13 +63,9 @@ class BlockedContactsViewModel @Inject constructor(private val storage: Storage)
return _state return _state
} }
fun unblock(context: Context) { fun unblock() {
storage.unblock(state.selectedItems) storage.setBlocked(state.selectedItems, false)
_state.value = state.copy(selectedItems = emptySet()) _state.value = state.copy(selectedItems = emptySet())
// TODO: Remove in UserConfig branch
GlobalScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
} }
fun select(selectedItem: Recipient, isSelected: Boolean) { fun select(selectedItem: Recipient, isSelected: Boolean) {

View File

@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.preferences
import android.Manifest
import androidx.fragment.app.Fragment
import androidx.preference.Preference
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences.Companion.setBooleanPreference
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.showSessionDialog
internal class CallToggleListener(
private val context: Fragment,
private val setCallback: (Boolean) -> Unit
) : Preference.OnPreferenceChangeListener {
override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean {
if (newValue == false) return true
// check if we've shown the info dialog and check for microphone permissions
context.showSessionDialog {
title(R.string.dialog_voice_video_title)
text(R.string.dialog_voice_video_message)
button(R.string.dialog_link_preview_enable_button_title, R.string.AccessibilityId_enable) { requestMicrophonePermission() }
cancelButton()
}
return false
}
private fun requestMicrophonePermission() {
Permissions.with(context)
.request(Manifest.permission.RECORD_AUDIO)
.onAllGranted {
setBooleanPreference(
context.requireContext(),
TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED,
true
)
setCallback(true)
}
.onAnyDenied { setCallback(false) }
.execute()
}
}

View File

@ -1,19 +0,0 @@
package org.thoughtcrime.securesms.preferences
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
class ChangeUiModeDialog : DialogFragment() {
companion object {
const val TAG = "ChangeUiModeDialog"
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = requireContext()
return android.app.AlertDialog.Builder(context)
.setTitle("TODO: remove this")
.show()
}
}

Some files were not shown because too many files have changed in this diff Show More