diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..b650b98b1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "libsession-util/libsession-util"] + path = libsession-util/libsession-util + url = https://github.com/oxen-io/libsession-util.git diff --git a/BUILDING.md b/BUILDING.md index e78207d96..f88509c68 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -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". 5. Default config options should be good enough. 6. Project initialization and building should proceed. +7. Clone submodules with `git submodule update --init --recursive` Contributing code ----------------- diff --git a/README.md b/README.md index c509dd956..723d50c75 100644 --- a/README.md +++ b/README.md @@ -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). - + ## Want to contribute? Found a bug or have a feature request? diff --git a/app/build.gradle b/app/build.gradle index 8a4241973..74b9f84f0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,3 +1,4 @@ + buildscript { repositories { google() @@ -13,6 +14,11 @@ buildscript { } } +plugins { + id 'kotlin-kapt' + id 'com.google.dagger.hilt.android' +} + apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'witness' @@ -22,11 +28,16 @@ apply plugin: 'com.google.gms.google-services' apply plugin: 'kotlinx-serialization' apply plugin: 'dagger.hilt.android.plugin' + configurations.all { exclude module: "commons-logging" } 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.recyclerview:recyclerview:1.2.1' implementation "com.google.android.material:material:$materialVersion" @@ -39,7 +50,6 @@ dependencies { implementation 'androidx.exifinterface:exifinterface:1.3.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' 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-livedata-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion" @@ -93,17 +103,13 @@ dependencies { exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection' } 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.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2' - implementation 'androidx.sqlite:sqlite-ktx:2.2.0' - implementation 'net.zetetic:sqlcipher-android:4.5.3@aar' - implementation ('com.googlecode.ez-vcard:ez-vcard:0.9.11') { - exclude group: 'com.fasterxml.jackson.core' - exclude group: 'org.freemarker' - } + implementation 'androidx.sqlite:sqlite-ktx:2.3.1' + implementation 'net.zetetic:sqlcipher-android:4.5.4@aar' implementation project(":libsignal") implementation project(":libsession") + implementation project(":libsession-util") implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion" implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version" implementation project(":liblazysodium") @@ -116,52 +122,62 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "nl.komponents.kovenant:kovenant:$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.github.tbruyelle:rxpermissions:0.10.2" implementation "com.github.ybq:Android-SpinKit:1.4.0" implementation "com.opencsv:opencsv:4.6" testImplementation "junit:junit:$junitVersion" 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.powermock:powermock-api-mockito:1.6.1' - testImplementation 'org.powermock:powermock-module-junit4:1.6.1' - testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.1' - testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1' + androidTestImplementation "org.mockito:mockito-android:4.10.0" + androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" 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" androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" // 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 - androidTestImplementation 'androidx.test:runner:1.4.0' - androidTestImplementation 'androidx.test:rules:1.4.0' + androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'androidx.test:rules:1.5.0' // Assertions - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.ext:truth:1.4.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.ext:truth:1.5.0' androidTestImplementation 'com.google.truth:truth:1.1.3' // Espresso dependencies - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-web:3.4.0' - androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.4.0' - androidTestUtil 'androidx.test:orchestrator:1.4.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1' + androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1' + androidTestUtil 'androidx.test:orchestrator:1.4.2' testImplementation 'org.robolectric:robolectric: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 canonicalVersionName = "1.16.9" +def canonicalVersionCode = 354 +def canonicalVersionName = "1.17.0" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, @@ -203,6 +219,13 @@ android { } } + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion '1.4.7' + } + defaultConfig { versionCode canonicalVersionCode * postFixSize versionName canonicalVersionName @@ -309,3 +332,8 @@ def autoResConfig() { .collect { matcher -> matcher.group(1) } .sort() } + +// Allow references to generated code +kapt { + correctErrorTypes = true +} diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt index 087d48689..eabe06f7d 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -1,5 +1,6 @@ package network.loki.messenger +import android.Manifest import android.app.Instrumentation import android.content.ClipboardManager 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.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry +import com.adevinta.android.barista.interaction.PermissionGranter import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf @@ -85,6 +87,8 @@ class HomeActivityTests { } onView(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click()) onView(withId(R.id.registerButton)).perform(ViewActions.click()) + // allow notification permission + PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS) } private fun goToMyChat() { @@ -100,6 +104,7 @@ class HomeActivityTests { copied = clipboardManager.primaryClip!!.getItemAt(0).text.toString() } 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()) } diff --git a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt new file mode 100644 index 000000000..59cb8ede0 --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt @@ -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? { + 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): 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)) + } + +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d2b2123d..aa81fafc2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,12 +29,16 @@ android:name="android.hardware.touchscreen" android:required="false" /> + + + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 53141534a..e4be27f24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -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.snode.SnodeModule; import org.session.libsession.utilities.Address; +import org.session.libsession.utilities.ConfigFactoryUpdateListener; import org.session.libsession.utilities.ProfilePictureUtilities; import org.session.libsession.utilities.SSKEnvironment; 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.helpers.SQLCipherOpenHelper; 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.DatabaseModule; import org.thoughtcrime.securesms.emoji.EmojiSource; @@ -106,6 +109,8 @@ import dagger.hilt.EntryPoints; import dagger.hilt.android.HiltAndroidApp; import kotlin.Unit; 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. @@ -116,7 +121,7 @@ import kotlinx.coroutines.Job; * @author Moxie Marlinspike */ @HiltAndroidApp -public class ApplicationContext extends Application implements DefaultLifecycleObserver { +public class ApplicationContext extends Application implements DefaultLifecycleObserver, ConfigFactoryUpdateListener { public static final String PREFERENCES_NAME = "SecureSMS-Preferences"; @@ -137,9 +142,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO private PersistentLogger persistentLogger; @Inject LokiAPIDatabase lokiAPIDatabase; - @Inject Storage storage; + @Inject public Storage storage; @Inject MessageDataProvider messageDataProvider; @Inject TextSecurePreferences textSecurePreferences; + @Inject ConfigFactory configFactory; CallMessageProcessor callMessageProcessor; MessagingModuleConfiguration messagingModuleConfiguration; @@ -157,6 +163,10 @@ public class ApplicationContext extends Application implements DefaultLifecycleO return (ApplicationContext) context.getApplicationContext(); } + public TextSecurePreferences getPrefs() { + return EntryPoints.get(getApplicationContext(), AppComponent.class).getPrefs(); + } + public DatabaseComponent getDatabaseComponent() { return EntryPoints.get(getApplicationContext(), DatabaseComponent.class); } @@ -183,6 +193,15 @@ public class ApplicationContext extends Application implements DefaultLifecycleO 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 public void onCreate() { DatabaseModule.init(this); @@ -191,7 +210,9 @@ public class ApplicationContext extends Application implements DefaultLifecycleO messagingModuleConfiguration = new MessagingModuleConfiguration(this, storage, messageDataProvider, - ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this)); + ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this), + configFactory + ); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); Log.i(TAG, "onCreate()"); startKovenant(); @@ -347,7 +368,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO } private void initializeProfileManager() { - this.profileManager = new ProfileManager(); + this.profileManager = new ProfileManager(this, configFactory); } private void initializeTypingStatusSender() { @@ -440,7 +461,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO poller.setUserPublicKey(userPublicKey); return; } - poller = new Poller(); + poller = new Poller(configFactory, new Timer()); } public void startPollingIfNeeded() { @@ -483,6 +504,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO }); } catch (Exception exception) { // 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)) { Log.d("Loki", "Failed to delete database."); } + configFactory.keyPairChanged(); Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt new file mode 100644 index 000000000..af38c31ff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt @@ -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() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt new file mode 100644 index 000000000..0390a3007 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt @@ -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() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.java b/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.java deleted file mode 100644 index 469629ed3..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.java +++ /dev/null @@ -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[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); - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.kt new file mode 100644 index 000000000..9a34c1ec4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.kt @@ -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(R.id.expiration_number_picker) + + fun updateText(index: Int) { + view.findViewById(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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java index b36fdb3ca..95ba15c82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java @@ -76,6 +76,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Locale; +import kotlin.Unit; import network.loki.messenger.R; /** @@ -318,9 +319,9 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity { @SuppressWarnings("CodeBlock2Expr") @SuppressLint({"InlinedApi", "StaticFieldLeak"}) private void handleSaveMedia(@NonNull Collection mediaRecords) { - final Context context = getContext(); + final Context context = requireContext(); - SaveAttachmentTask.showWarningDialog(context, (dialogInterface, which) -> { + SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> { Permissions.with(this) .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) .maxSdkVersion(Build.VERSION_CODES.P) @@ -362,7 +363,8 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity { }.execute(); }) .execute(); - }, mediaRecords.size()); + return Unit.INSTANCE; + }); } private void sendMediaSavedNotificationIfNeeded() { @@ -374,41 +376,26 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity { @SuppressLint("StaticFieldLeak") private void handleDeleteMedia(@NonNull Collection mediaRecords) { 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()); - builder.setIconAttribute(R.attr.dialog_alert_icon); - builder.setTitle(confirmTitle); - builder.setMessage(confirmMessage); - builder.setCancelable(true); - - builder.setPositiveButton(R.string.delete, (dialogInterface, i) -> { - new ProgressDialogAsyncTask(getContext(), - R.string.MediaOverviewActivity_Media_delete_progress_title, - R.string.MediaOverviewActivity_Media_delete_progress_message) - { - @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()); - } + DeleteMediaDialog.show( + requireContext(), + recordCount, + () -> new ProgressDialogAsyncTask( + requireContext(), + R.string.MediaOverviewActivity_Media_delete_progress_title, + R.string.MediaOverviewActivity_Media_delete_progress_message) { + @Override + protected Void doInBackground(MediaDatabase.MediaRecord... records) { + if (records == null || records.length == 0) { return null; } - }.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])); - }); - builder.setNegativeButton(android.R.string.cancel, null); - builder.show(); + for (MediaDatabase.MediaRecord record : records) { + AttachmentUtil.deleteAttachment(getContext(), record.getAttachment()); + } + return null; + } + }.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()]))); } private void handleSelectAllMedia() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index 6544c2ab8..f19a1fc45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -85,6 +85,7 @@ import java.io.IOException; import java.util.Locale; import java.util.WeakHashMap; +import kotlin.Unit; 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) { Intent previewIntent = null; if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) { @@ -416,7 +421,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im MediaItem mediaItem = getCurrentMediaItem(); if (mediaItem == null) return; - SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> { + SaveAttachmentTask.showWarningDialog(this, 1, () -> { Permissions.with(this) .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) .maxSdkVersion(Build.VERSION_CODES.P) @@ -433,6 +438,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im } }) .execute(); + return Unit.INSTANCE; }); } @@ -449,29 +455,20 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im return; } - AlertDialog.Builder builder = new AlertDialog.Builder(this); - builder.setIconAttribute(R.attr.dialog_alert_icon); - builder.setTitle(R.string.MediaPreviewActivity_media_delete_confirmation_title); - builder.setMessage(R.string.MediaPreviewActivity_media_delete_confirmation_message); - builder.setCancelable(true); - - builder.setPositiveButton(R.string.delete, (dialogInterface, which) -> { - new AsyncTask() { - @Override - protected Void doInBackground(Void... voids) { - if (mediaItem.attachment == null) { - return null; - } - AttachmentUtil.deleteAttachment(MediaPreviewActivity.this.getApplicationContext(), - mediaItem.attachment); - return null; - } - }.execute(); + DeleteMediaPreviewDialog.show(this, () -> { + new AsyncTask() { + @Override + protected Void doInBackground(Void... voids) { + DatabaseAttachment attachment = mediaItem.attachment; + if (attachment != null) { + AttachmentUtil.deleteAttachment(getApplicationContext(), attachment); + } + return null; + } + }.execute(); finish(); }); - builder.setNegativeButton(android.R.string.cancel, null); - builder.show(); } @Override @@ -531,7 +528,6 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im @Override public void onLoadFinished(@NonNull Loader> loader, @Nullable Pair data) { if (data != null) { - @SuppressWarnings("ConstantConditions") CursorPagerAdapter adapter = new CursorPagerAdapter(this, GlideApp.with(this), getWindow(), data.first, data.second, leftIsRecent); mediaPager.setAdapter(adapter); adapter.setActive(true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewArgs.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewArgs.kt new file mode 100644 index 000000000..00e2c3d6d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewArgs.kt @@ -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?, +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java deleted file mode 100644 index acca9f837..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java +++ /dev/null @@ -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); - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt new file mode 100644 index 000000000..f294e387f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt @@ -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 }) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt new file mode 100644 index 000000000..141a98e4a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt @@ -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, + currentSelected: Int = 0, + onSelect: (Int) -> Unit + ) = singleChoiceItems(options.toTypedArray(), currentSelected, onSelect) + + fun singleChoiceItems( + options: Array, + currentSelected: Int = 0, + onSelect: (Int) -> Unit + ): AlertDialog.Builder = dialogBuilder.setSingleChoiceItems( + options, + currentSelected + ) { dialog, it -> onSelect(it); dialog.dismiss() } + + fun items( + options: Array, + 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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt index 7e732d1aa..b87eac12c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt @@ -249,17 +249,12 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { viewModel.callState.collect { state -> Log.d("Loki", "Consuming view model state $state") when (state) { - CALL_RINGING -> { - if (wantsToAnswer) { - answerCall() - wantsToAnswer = false - } - } - CALL_OUTGOING -> { - } - CALL_CONNECTED -> { + CALL_RINGING -> if (wantsToAnswer) { + answerCall() wantsToAnswer = false } + CALL_CONNECTED -> wantsToAnswer = false + else -> {} } updateControls(state) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt index fcc8a97ca..d2fd5b548 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -2,12 +2,14 @@ package org.thoughtcrime.securesms.components import android.content.Context import android.util.AttributeSet +import android.view.LayoutInflater import android.view.View import android.widget.ImageView import android.widget.RelativeLayout import com.bumptech.glide.load.engine.DiskCacheStrategy import network.loki.messenger.R import network.loki.messenger.databinding.ViewProfilePictureBinding +import network.loki.messenger.databinding.ViewUserBinding import org.session.libsession.avatars.ContactColors import org.session.libsession.avatars.PlaceholderAvatarPhoto 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.recipients.Recipient import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests class ProfilePictureView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RelativeLayout(context, attrs) { - private val binding: ViewProfilePictureBinding by lazy { ViewProfilePictureBinding.bind(this) } - lateinit var glide: GlideRequests + private val binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this) + private val glide: GlideRequests = GlideApp.with(this) var publicKey: String? = null var displayName: String? = null var additionalPublicKey: String? = null @@ -31,13 +34,17 @@ class ProfilePictureView @JvmOverloads constructor( var isLarge = false private val profilePicturesCache = mutableMapOf() - private val unknownRecipientDrawable = ResourceContactPhoto(R.drawable.ic_profile_default) - .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) - private val unknownOpenGroupDrawable = ResourceContactPhoto(R.drawable.ic_notification) - .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) + private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default) + .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) } + private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification) + .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) } // endregion + constructor(context: Context, sender: Recipient): this(context) { + update(sender) + } + // region Updating fun update(recipient: Recipient) { fun getUserDisplayName(publicKey: String): String { @@ -51,12 +58,19 @@ class ProfilePictureView @JvmOverloads constructor( .sorted() .take(2) .toMutableList() - val pk = members.getOrNull(0)?.serialize() ?: "" - publicKey = pk - displayName = getUserDisplayName(pk) - val apk = members.getOrNull(1)?.serialize() ?: "" - additionalPublicKey = apk - additionalDisplayName = getUserDisplayName(apk) + if (members.size <= 1) { + publicKey = "" + displayName = "" + additionalPublicKey = "" + additionalDisplayName = "" + } 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) { val publicKey = GroupUtil.getDecodedOpenGroupInbox(recipient.address.serialize()) this.publicKey = publicKey @@ -72,7 +86,6 @@ class ProfilePictureView @JvmOverloads constructor( } fun update() { - if (!this::glide.isInitialized) return val publicKey = publicKey ?: return val additionalPublicKey = additionalPublicKey if (additionalPublicKey != null) { @@ -104,31 +117,38 @@ class ProfilePictureView @JvmOverloads constructor( if (publicKey.isNotEmpty()) { val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false) if (profilePicturesCache[imageView] == recipient) return + profilePicturesCache[imageView] = recipient val signalProfilePicture = recipient.contactPhoto val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject glide.clear(imageView) + val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") + if (signalProfilePicture != null && avatar != "0" && avatar != "") { glide.load(signalProfilePicture) .placeholder(unknownRecipientDrawable) .centerCrop() - .error(unknownRecipientDrawable) + .error(glide.load(placeholder)) .diskCacheStrategy(DiskCacheStrategy.NONE) .circleCrop() .into(imageView) } else if (recipient.isOpenGroupRecipient && recipient.groupAvatarId == null) { - imageView.setImageDrawable(unknownOpenGroupDrawable) + glide.load(unknownOpenGroupDrawable) + .centerCrop() + .circleCrop() + .into(imageView) } else { - val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") glide.load(placeholder) .placeholder(unknownRecipientDrawable) .centerCrop() + .circleCrop() .diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView) } - profilePicturesCache[imageView] = recipient } else { - imageView.setImageDrawable(null) + glide.load(unknownRecipientDrawable) + .centerCrop() + .into(imageView) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index e88cf1d08..36a8c1adf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -7,6 +7,7 @@ import android.view.View import android.widget.LinearLayout import network.loki.messenger.R import network.loki.messenger.databinding.ViewUserBinding +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities @@ -47,15 +48,14 @@ class UserView : LinearLayout { // region Updating fun bind(user: Recipient, glide: GlideRequests, actionIndicator: ActionIndicator, isSelected: Boolean = false) { + val isLocalUser = user.isLocalNumber fun getUserDisplayName(publicKey: String): String { + if (isLocalUser) return context.getString(R.string.MessageRecord_you) val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(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() - binding.profilePictureView.root.glide = glide - binding.profilePictureView.root.update(user) + binding.profilePictureView.update(user) binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24) binding.nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address) when (actionIndicator) { @@ -87,7 +87,7 @@ class UserView : LinearLayout { } fun unbind() { - binding.profilePictureView.root.recycle() + binding.profilePictureView.recycle() } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt index 99e7c9061..68e2f975c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt @@ -32,14 +32,13 @@ class ContactListAdapter( class ContactViewHolder(private val binding: ViewContactBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(contact: ContactListItem.Contact, glide: GlideRequests, listener: (Recipient) -> Unit) { - binding.profilePictureView.root.glide = glide - binding.profilePictureView.root.update(contact.recipient) + binding.profilePictureView.update(contact.recipient) binding.nameTextView.text = contact.displayName binding.root.setOnClickListener { listener(contact.recipient) } } fun unbind() { - binding.profilePictureView.root.recycle() + binding.profilePictureView.recycle() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt index 2e62932ab..92f050f76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt @@ -55,7 +55,7 @@ class NewConversationHomeFragment : Fragment() { val displayName = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionId ContactListItem.Contact(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() contactGroups.remove(unknownSectionTitle)?.let { contactGroups.put(unknownSectionTitle, it) } adapter.items = contactGroups.flatMap { entry -> listOf(ContactListItem.Header(entry.key)) + entry.value } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 7844ca4c2..233d43eae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -3,28 +3,50 @@ package org.thoughtcrime.securesms.conversation.v2 import android.Manifest import android.animation.FloatEvaluator 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.database.Cursor import android.graphics.Rect import android.graphics.Typeface 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.text.SpannableStringBuilder +import android.text.SpannedString import android.text.TextUtils +import android.text.style.StyleSpan import android.util.Pair 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.RelativeLayout import android.widget.Toast +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels 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.fragment.app.DialogFragment +import androidx.lifecycle.Lifecycle import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager @@ -32,6 +54,11 @@ import androidx.recyclerview.widget.RecyclerView import com.annimon.stream.Stream import dagger.hilt.android.AndroidEntryPoint 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.withContext 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.utilities.SessionId 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.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.recipients.Recipient 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.hexEncodedPrivateKey import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.ExpirationDialog import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.attachments.ScreenshotObserver 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.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener 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.LinkPreviewDialog 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.search.SearchBottomBar 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.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.MessageRecord 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.mediasend.Media 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.reactions.ReactionsDialogFragment 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.util.* +import java.util.Locale import java.util.concurrent.ExecutionException +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject @@ -184,11 +249,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe it } val recipient = Recipient.from(this, address, false) - threadId = threadDb.getOrCreateThreadIdFor(recipient) + threadId = storage.getOrCreateThreadIdFor(recipient.address) } } ?: finish() } - viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair()) + viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair(), contentResolver) } private var actionMode: ActionMode? = null private var unreadCount = 0 @@ -209,6 +274,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val searchViewModel: SearchViewModel by viewModels() var searchViewItem: MenuItem? = null + private val bufferedLastSeenChannel = Channel(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST) private var emojiPickerVisible = false private val isScrolledToBottom: Boolean @@ -228,11 +294,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe 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 { - val cursor = mmsSmsDb.getConversation(viewModel.threadId, !isIncomingMessageRequestThread()) + val cursor = mmsSmsDb.getConversation(viewModel.threadId, reverseMessageList) val adapter = ConversationAdapter( this, cursor, + storage.getLastSeen(viewModel.threadId), + reverseMessageList, onItemPress = { 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 messageToScrollTimestamp = AtomicLong(-1) private val messageToScrollAuthor = AtomicReference(null) + private val firstLoad = AtomicBoolean(true) private lateinit var reactionDelegate: ConversationReactionDelegate private val reactWithAnyEmojiStartPage = -1 @@ -318,28 +391,31 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe setUpUiStateObserver() binding!!.scrollToBottomButton.setOnClickListener { val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener + val targetPosition = if (reverseMessageList) 0 else adapter.itemCount if (layoutManager.isSmoothScrolling) { - binding?.conversationRecyclerView?.scrollToPosition(0) + binding?.conversationRecyclerView?.scrollToPosition(targetPosition) } else { // 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 // 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 // enough to give a similar scroll effect without having to load everything) - val position = layoutManager.findFirstVisibleItemPosition() - if (position > 10) { - binding?.conversationRecyclerView?.scrollToPosition(10) - } +// val position = if (reverseMessageList) layoutManager.findFirstVisibleItemPosition() else layoutManager.findLastVisibleItemPosition() +// val targetBuffer = if (reverseMessageList) 10 else Math.max(0, (adapter.itemCount - 1) - 10) +// if (position > targetBuffer) { +// binding?.conversationRecyclerView?.scrollToPosition(targetBuffer) +// } binding?.conversationRecyclerView?.post { - binding?.conversationRecyclerView?.smoothScrollToPosition(0) + binding?.conversationRecyclerView?.smoothScrollToPosition(targetPosition) } } } updateUnreadCountIndicator() updateSubtitle() + updatePlaceholder() setUpBlockedBanner() binding!!.searchBottomBar.setEventListener(this) updateSendAfterApprovalText() @@ -349,20 +425,30 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val weakActivity = WeakReference(this) lifecycleScope.launch(Dispatchers.IO) { - unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId) - // 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 // transitioning to the activity 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) { setUpRecyclerView() setUpTypingObserver() setUpRecipientObserver() getLatestOpenGroupInfoIfNeeded() 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) reactionDelegate = ConversationReactionDelegate(reactionOverlayStub) 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() { super.onResume() ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId) - val recipient = viewModel.recipient ?: return - - lifecycleScope.launch(Dispatchers.IO) { - threadDb.markAllAsRead(viewModel.threadId, recipient.isOpenGroupRecipient) - } contentResolver.registerContentObserver( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, @@ -406,23 +501,45 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe push(intent, false) } - override fun showDialog(baseDialog: BaseDialog, tag: String?) { - baseDialog.show(supportFragmentManager, tag) + override fun showDialog(dialogFragment: DialogFragment, tag: String?) { + dialogFragment.show(supportFragmentManager, tag) } override fun onCreateLoader(id: Int, bundle: Bundle?): Loader { - return ConversationLoader(viewModel.threadId, !isIncomingMessageRequestThread(), this@ConversationActivityV2) + return ConversationLoader(viewModel.threadId, reverseMessageList, this@ConversationActivityV2) } override fun onLoadFinished(loader: Loader, cursor: Cursor?) { + val oldCount = adapter.itemCount + val newCount = cursor?.count ?: 0 adapter.changeCursor(cursor) + if (cursor != null) { val messageTimestamp = messageToScrollTimestamp.getAndSet(-1) 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) { - 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) { @@ -432,7 +549,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // called from onCreate private fun setUpRecyclerView() { binding!!.conversationRecyclerView.adapter = adapter - val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, !isIncomingMessageRequestThread()) + val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, reverseMessageList) binding!!.conversationRecyclerView.layoutManager = layoutManager // Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will) LoaderManager.getInstance(this).restartLoader(0, null, this) @@ -441,6 +558,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { handleRecyclerViewScrolled() } + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + + } }) binding!!.conversationRecyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> @@ -467,10 +588,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe R.dimen.small_profile_picture_size } val size = resources.getDimension(sizeID).roundToInt() - binding.toolbarContent.profilePictureView.root.layoutParams = LinearLayout.LayoutParams(size, size) - binding.toolbarContent.profilePictureView.root.glide = glide + binding.toolbarContent.profilePictureView.layoutParams = LinearLayout.LayoutParams(size, size) MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this) - val profilePictureView = binding.toolbarContent.profilePictureView.root + val profilePictureView = binding.toolbarContent.profilePictureView viewModel.recipient?.let(profilePictureView::update) } @@ -576,7 +696,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID binding?.blockedBannerTextView?.text = resources.getString(R.string.activity_conversation_blocked_banner_text, name) binding?.blockedBanner?.isVisible = recipient.isBlocked - binding?.blockedBanner?.setOnClickListener { viewModel.unblock(this@ConversationActivityV2) } + binding?.blockedBanner?.setOnClickListener { viewModel.unblock() } } private fun setUpLinkPreviewObserver() { @@ -609,15 +729,37 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (uiState.isMessageRequestAccepted == true) { 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 lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return - if (lastSeenItemPosition <= 3) { return } + val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return -1 + + // 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) + return lastSeenItemPosition + } + + private fun highlightViewAtPosition(position: Int) { + binding?.conversationRecyclerView?.post { + (layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight() + } } override fun onPrepareOptionsMenu(menu: Menu): Boolean { @@ -658,7 +800,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe updateSendAfterApprovalText() showOrHideInputIfNeeded() - binding?.toolbarContent?.profilePictureView?.root?.update(threadRecipient) + binding?.toolbarContent?.profilePictureView?.update(threadRecipient) binding?.toolbarContent?.conversationTitleView?.text = when { threadRecipient.isLocalNumber -> getString(R.string.note_to_self) else -> threadRecipient.toShortString() @@ -701,11 +843,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun acceptMessageRequest() { binding?.messageRequestBar?.isVisible = false - binding?.conversationRecyclerView?.layoutManager = - LinearLayoutManager(this, LinearLayoutManager.VERTICAL, true) - adapter.notifyDataSetChanged() viewModel.acceptMessageRequest() - LoaderManager.getInstance(this).restartLoader(0, null, this) + lifecycleScope.launch(Dispatchers.IO) { ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) } @@ -903,17 +1042,60 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } 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 wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom showScrollToBottomButtonIfApplicable() - val firstVisiblePosition = layoutManager?.findFirstVisibleItemPosition() ?: -1 - unreadCount = min(unreadCount, firstVisiblePosition).coerceAtLeast(0) + val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition() + 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() } + 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() { binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0 } @@ -965,21 +1147,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun block(deleteThread: Boolean) { - val title = R.string.RecipientPreferenceActivity_block_this_contact_question - val message = R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact - val dialog = AlertDialog.Builder(this) - .setTitle(title) - .setMessage(message) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.RecipientPreferenceActivity_block) { _, _ -> - viewModel.block(this@ConversationActivityV2) + showSessionDialog { + title(R.string.RecipientPreferenceActivity_block_this_contact_question) + text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) + destructiveButton(R.string.RecipientPreferenceActivity_block, R.string.AccessibilityId_block_confirm) { + viewModel.block() if (deleteThread) { viewModel.deleteThread() finish() } - }.show() - val button = dialog.getButton(DialogInterface.BUTTON_POSITIVE) - button.setContentDescription("Confirm block") + } + cancelButton() + } } override fun copySessionID(sessionId: String) { @@ -1006,28 +1185,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val group = groupDb.getGroup(thread.address.toGroupString()).orNull() if (group?.isActive == false) { return } } - ExpirationDialog.show(this, thread.expireMessages) { expirationTime: Int -> - recipientDb.setExpireMessages(thread, expirationTime) + showExpirationDialog(thread.expireMessages) { expirationTime -> + storage.setExpirationTimer(thread.address.serialize(), expirationTime) val message = ExpirationTimerUpdate(expirationTime) message.recipient = thread.address.serialize() message.sentTimestamp = SnodeAPI.nowWithOffset - val expiringMessageManager = ApplicationContext.getInstance(this).expiringMessageManager - expiringMessageManager.setExpirationTimer(message) + ApplicationContext.getInstance(this).expiringMessageManager.setExpirationTimer(message) MessageSender.send(message, thread.address) invalidateOptionsMenu() } } override fun unblock() { - val title = R.string.ConversationActivity_unblock_this_contact_question - val message = R.string.ConversationActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact - AlertDialog.Builder(this) - .setTitle(title) - .setMessage(message) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.ConversationActivity_unblock) { _, _ -> - viewModel.unblock(this@ConversationActivityV2) - }.show() + showSessionDialog { + title(R.string.ConversationActivity_unblock_this_contact_question) + text(R.string.ConversationActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact) + destructiveButton( + R.string.ConversationActivity_unblock, + R.string.AccessibilityId_block_confirm + ) { viewModel.unblock() } + cancelButton() + } } // `position` is the adapter position; not the visual position @@ -1371,11 +1549,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe 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) } else { 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) { @@ -1393,19 +1577,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false) { - val recipient = viewModel.recipient ?: return + private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair? { + val recipient = viewModel.recipient ?: return null + val sentTimestamp = SnodeAPI.nowWithOffset processMessageRequestApproval() val text = getMessageBody() val userPublicKey = textSecurePreferences.getLocalNumber() val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) { val dialog = SendSeedDialog { sendTextOnlyMessage(true) } - return dialog.show(supportFragmentManager, "Send Seed Dialog") + dialog.show(supportFragmentManager, "Send Seed Dialog") + return null } // Create the message val message = VisibleMessage() - message.sentTimestamp = SnodeAPI.nowWithOffset + message.sentTimestamp = sentTimestamp message.text = text val outgoingTextMessage = OutgoingTextMessage.from(message, recipient) // Clear the input bar @@ -1422,14 +1608,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe MessageSender.send(message, recipient.address) // Send a typing stopped message ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) + return Pair(recipient.address, sentTimestamp) } - private fun sendAttachments(attachments: List, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null) { - val recipient = viewModel.recipient ?: return + private fun sendAttachments(attachments: List, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null): Pair? { + val recipient = viewModel.recipient ?: return null + val sentTimestamp = SnodeAPI.nowWithOffset processMessageRequestApproval() // Create the message val message = VisibleMessage() - message.sentTimestamp = SnodeAPI.nowWithOffset + message.sentTimestamp = sentTimestamp message.text = body val quote = quotedMessage?.let { val quotedAttachments = (it as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: listOf() @@ -1463,28 +1651,28 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe MessageSender.send(message, recipient.address, attachments, quote, linkPreview) // Send a typing stopped message ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) + return Pair(recipient.address, sentTimestamp) } private fun showGIFPicker() { val hasSeenGIFMetaDataWarning: Boolean = textSecurePreferences.hasSeenGIFMetaDataWarning() if (!hasSeenGIFMetaDataWarning) { - val builder = AlertDialog.Builder(this) - builder.setTitle(R.string.giphy_permission_title) - builder.setMessage(R.string.giphy_permission_message) - builder.setPositiveButton(R.string.continue_2) { dialog: DialogInterface, _: Int -> - textSecurePreferences.setHasSeenGIFMetaDataWarning() - AttachmentManager.selectGif(this, PICK_GIF) - dialog.dismiss() + showSessionDialog { + title(R.string.giphy_permission_title) + text(R.string.giphy_permission_message) + button(R.string.continue_2) { + textSecurePreferences.setHasSeenGIFMetaDataWarning() + selectGif() + } + cancelButton() } - builder.setNegativeButton(R.string.cancel) { dialog: DialogInterface, _: Int -> - dialog.dismiss() - } - builder.create().show() } else { - AttachmentManager.selectGif(this, PICK_GIF) + selectGif() } } + private fun selectGif() = AttachmentManager.selectGif(this, PICK_GIF) + private fun showDocumentPicker() { AttachmentManager.selectDocument(this, PICK_DOCUMENT) } @@ -1584,7 +1772,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe showVoiceMessageUI() window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 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 { Permissions.with(this) .request(Manifest.permission.RECORD_AUDIO) @@ -1631,35 +1819,23 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null } if (recipient.isOpenGroupRecipient) { val messageCount = 1 - val builder = AlertDialog.Builder(this) - builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) - builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) - builder.setCancelable(true) - builder.setPositiveButton(R.string.delete) { _, _ -> - for (message in messages) { - viewModel.deleteForEveryone(message) - } - endActionMode() + + showSessionDialog { + title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) + text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) + button(R.string.delete) { messages.forEach(viewModel::deleteForEveryone); endActionMode() } + cancelButton { endActionMode() } } - builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> - dialog.dismiss() - endActionMode() - } - builder.show() } else if (allSentByCurrentUser && allHasHash) { val bottomSheet = DeleteOptionsBottomSheet() bottomSheet.recipient = recipient bottomSheet.onDeleteForMeTapped = { - for (message in messages) { - viewModel.deleteLocally(message) - } + messages.forEach(viewModel::deleteLocally) bottomSheet.dismiss() endActionMode() } bottomSheet.onDeleteForEveryoneTapped = { - for (message in messages) { - viewModel.deleteForEveryone(message) - } + messages.forEach(viewModel::deleteForEveryone) bottomSheet.dismiss() endActionMode() } @@ -1670,54 +1846,32 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe bottomSheet.show(supportFragmentManager, bottomSheet.tag) } else { val messageCount = 1 - val builder = AlertDialog.Builder(this) - builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) - builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) - builder.setCancelable(true) - builder.setPositiveButton(R.string.delete) { _, _ -> - for (message in messages) { - viewModel.deleteLocally(message) - } - endActionMode() + + showSessionDialog { + title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) + text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) + button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() } + cancelButton(::endActionMode) } - builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> - dialog.dismiss() - endActionMode() - } - builder.show() } } override fun banUser(messages: Set) { - val builder = AlertDialog.Builder(this) - builder.setTitle(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.") - builder.setCancelable(true) - builder.setPositiveButton(R.string.ban) { _, _ -> - viewModel.banUser(messages.first().individualRecipient) - endActionMode() + showSessionDialog { + title(R.string.ConversationFragment_ban_selected_user) + text("This will ban the selected user from this room. It won't ban them from other rooms.") + button(R.string.ban) { viewModel.banUser(messages.first().individualRecipient); endActionMode() } + cancelButton(::endActionMode) } - builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> - dialog.dismiss() - endActionMode() - } - builder.show() } override fun banAndDeleteAll(messages: Set) { - val builder = AlertDialog.Builder(this) - builder.setTitle(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.") - builder.setCancelable(true) - builder.setPositiveButton(R.string.ban) { _, _ -> - viewModel.banAndDeleteAll(messages.first().individualRecipient) - endActionMode() + showSessionDialog { + title(R.string.ConversationFragment_ban_selected_user) + 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.") + button(R.string.ban) { viewModel.banAndDeleteAll(messages.first().individualRecipient); endActionMode() } + cancelButton(::endActionMode) } - builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> - dialog.dismiss() - endActionMode() - } - builder.show() } override fun copyMessages(messages: Set) { @@ -1772,16 +1926,30 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe 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) { - val intent = Intent(this, MessageDetailActivity::class.java) - intent.putExtra(MessageDetailActivity.MESSAGE_TIMESTAMP, messages.first().timestamp) - push(intent) + Intent(this, MessageDetailActivity::class.java) + .apply { putExtra(MESSAGE_TIMESTAMP, messages.first().timestamp) } + .let { handleMessageDetail.launch(it) } + endActionMode() } override fun saveAttachment(messages: Set) { val message = messages.first() as MmsMessageRecord - SaveAttachmentTask.showWarningDialog(this, { _, _ -> + SaveAttachmentTask.showWarningDialog(this) { Permissions.with(this) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .maxSdkVersion(Build.VERSION_CODES.P) @@ -1809,12 +1977,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Toast.LENGTH_LONG).show() } .execute() - }) + } } override fun reply(messages: Set) { val recipient = viewModel.recipient ?: return - binding?.inputBar?.draftQuote(recipient, messages.first(), glide) + messages.firstOrNull()?.let { binding?.inputBar?.draftQuote(recipient, it, glide) } endActionMode() } @@ -1867,7 +2035,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (result == null) return@Observer if (result.getResults().isNotEmpty()) { result.getResults()[result.position]?.let { - jumpToMessage(it.messageRecipient.address, it.sentTimestampMs) { + jumpToMessage(it.messageRecipient.address, it.sentTimestampMs, true) { searchViewModel.onMissingResult() } } } @@ -1904,15 +2072,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe 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, { - mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author) - }) { p: Int -> moveToMessagePosition(p, onMessageNotFound) } + mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author, reverseMessageList) + }) { 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) { binding?.conversationRecyclerView?.scrollToPosition(position) + + if (highlight) { + runOnUiThread { + highlightViewAtPosition(position) + } + } } else { onMessageNotFound?.run() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index 85d3c8e6d..6013af5ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.conversation.v2 -import android.app.AlertDialog import android.content.Context import android.content.Intent 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.mms.GlideRequests import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity +import org.thoughtcrime.securesms.showSessionDialog +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.min class ConversationAdapter( context: Context, cursor: Cursor, + originalLastSeen: Long, + private val isReversed: Boolean, private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit, private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit, @@ -52,6 +56,8 @@ class ConversationAdapter( private val updateQueue = Channel(1024, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val contactCache = SparseArray(100) private val contactLoadedCache = SparseBooleanArray(100) + private val lastSeen = AtomicLong(originalLastSeen) + init { lifecycleCoroutineScope.launch(IO) { while (isActive) { @@ -128,6 +134,7 @@ class ConversationAdapter( searchQuery, contact, senderId, + lastSeen.get(), visibleMessageViewDelegate, onAttachmentNeedsDownload ) @@ -146,17 +153,15 @@ class ConversationAdapter( viewHolder.view.bind(message, messageBefore) if (message.isCallLog && message.isFirstMissedCall) { viewHolder.view.setOnClickListener { - AlertDialog.Builder(context) - .setTitle(R.string.CallNotificationBuilder_first_call_title) - .setMessage(R.string.CallNotificationBuilder_first_call_message) - .setPositiveButton(R.string.activity_settings_title) { _, _ -> - val intent = Intent(context, PrivacySettingsActivity::class.java) - context.startActivity(intent) + context.showSessionDialog { + title(R.string.CallNotificationBuilder_first_call_title) + text(R.string.CallNotificationBuilder_first_call_message) + button(R.string.activity_settings_title) { + Intent(context, PrivacySettingsActivity::class.java) + .let(context::startActivity) } - .setNeutralButton(R.string.cancel) { d, _ -> - d.dismiss() - } - .show() + cancelButton() + } } } else { viewHolder.view.setOnClickListener(null) @@ -185,14 +190,18 @@ class ConversationAdapter( private fun getMessageBefore(position: Int, cursor: Cursor): MessageRecord? { // The message that's visually before the current one is actually after the current // 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 } private fun getMessageAfter(position: Int, cursor: Cursor): MessageRecord? { // The message that's visually after the current one is actually before the current // 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 } @@ -219,11 +228,30 @@ class ConversationAdapter( fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? { 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) { - cursor.moveToPosition(i) - val message = messageDB.readerFor(cursor).current - if (message.isOutgoing || message.dateReceived <= lastSeenTimestamp) { return i } + if (isReversed) { + cursor.moveToPosition(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 } @@ -233,8 +261,8 @@ class ConversationAdapter( if (timestamp <= 0L || cursor == null || !isActiveCursor) return null for (i in 0 until itemCount) { cursor.moveToPosition(i) - val message = messageDB.readerFor(cursor).current - if (message.dateSent == timestamp) { return i } + val (_, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor) + if (dateSent == timestamp) { return i } } return null } @@ -243,4 +271,11 @@ class ConversationAdapter( this.searchQuery = query 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 + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.java index 20462bef3..eee8b5ecd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.java @@ -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))); } // 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 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))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index b8b460b60..13736974b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -1,10 +1,10 @@ package org.thoughtcrime.securesms.conversation.v2 -import android.content.Context +import android.content.ContentResolver import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope +import app.cash.copper.flow.observeQuery import com.goterl.lazysodium.utils.KeyPair import dagger.assisted.Assisted 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.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.repository.ConversationRepository -import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import java.util.UUID class ConversationViewModel( val threadId: Long, val edKeyPair: KeyPair?, + private val contentResolver: ContentResolver, private val repository: ConversationRepository, private val storage: Storage ) : ViewModel() { @@ -37,7 +38,7 @@ class ConversationViewModel( val showSendAfterApprovalText: Boolean get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false - private val _uiState = MutableStateFlow(ConversationUiState()) + private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true)) val uiState: StateFlow = _uiState private var _recipient: RetrieveOnce = RetrieveOnce { @@ -61,6 +62,18 @@ class ConversationViewModel( ?.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) { GlobalScope.launch(Dispatchers.IO) { repository.saveDraft(threadId, text) @@ -81,27 +94,17 @@ class ConversationViewModel( repository.inviteContacts(threadId, contacts) } - fun block(context: Context) { + fun block() { val recipient = recipient ?: return Log.w("Loki", "Recipient was null for block action") if (recipient.isContactRecipient) { 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") if (recipient.isContactRecipient) { 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 interface AssistedFactory { - fun create(threadId: Long, edKeyPair: KeyPair?): Factory + fun create(threadId: Long, edKeyPair: KeyPair?, contentResolver: ContentResolver): Factory } @Suppress("UNCHECKED_CAST") class Factory @AssistedInject constructor( @Assisted private val threadId: Long, @Assisted private val edKeyPair: KeyPair?, + @Assisted private val contentResolver: ContentResolver, private val repository: ConversationRepository, private val storage: Storage ) : ViewModelProvider.Factory { override fun create(modelClass: Class): 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( val uiMessages: List = emptyList(), - val isMessageRequestAccepted: Boolean? = null + val isMessageRequestAccepted: Boolean? = null, + val conversationExists: Boolean ) data class RetrieveOnce(val retrieval: () -> T?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index 0938b21dd..61732827f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -1,99 +1,401 @@ package org.thoughtcrime.securesms.conversation.v2 +import android.annotation.SuppressLint +import android.content.Intent import android.os.Bundle -import android.view.View -import androidx.core.view.isVisible +import android.view.LayoutInflater +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 kotlinx.coroutines.launch import network.loki.messenger.R -import network.loki.messenger.databinding.ActivityMessageDetailBinding -import org.session.libsession.messaging.MessagingModuleConfiguration -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 network.loki.messenger.databinding.ViewVisibleMessageContentBinding +import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities import org.thoughtcrime.securesms.database.Storage -import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.DateUtils -import java.text.SimpleDateFormat -import java.util.* +import org.thoughtcrime.securesms.ui.AppTheme +import org.thoughtcrime.securesms.ui.Avatar +import org.thoughtcrime.securesms.ui.CarouselNextButton +import org.thoughtcrime.securesms.ui.CarouselPrevButton +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 @AndroidEntryPoint -class MessageDetailActivity: PassphraseRequiredActionBarActivity() { - private lateinit var binding: ActivityMessageDetailBinding - var messageRecord: MessageRecord? = null +class MessageDetailActivity : PassphraseRequiredActionBarActivity() { @Inject lateinit var storage: Storage - // region Settings + private val viewModel: MessageDetailsViewModel by viewModels() + companion object { // Extras 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) { super.onCreate(savedInstanceState, ready) - binding = ActivityMessageDetailBinding.inflate(layoutInflater) - setContentView(binding.root) + 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, - // so the author of the messages must be the current user. - val author = Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!) - messageRecord = DatabaseComponent.get(this).mmsSmsDatabase().getMessageFor(timestamp, author) ?: run { - finish() - return - } - val threadId = messageRecord!!.threadId - val openGroup = storage.getOpenGroup(threadId) - val blindedKey = openGroup?.let { group -> - val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return@let null - val blindingEnabled = storage.getServerCapabilities(group.server).contains(OpenGroupApi.Capability.BLIND.name.lowercase()) - 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() + + viewModel.timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L) + + ComposeView(this) + .apply { setContent { MessageDetailsScreen() } } + .let(::setContentView) + + lifecycleScope.launch { + viewModel.eventFlow.collect { + when (it) { + Event.Finish -> finish() + is Event.StartMediaPreview -> startActivity( + getPreviewIntent(this@MessageDetailActivity, it.args) + ) + } + } } } - fun updateContent() { - val dateLocale = Locale.getDefault() - val dateFormatter: SimpleDateFormat = DateUtils.getDetailedDateFormatter(this, dateLocale) - binding.sentTime.text = dateFormatter.format(Date(messageRecord!!.dateSent)) - - val errorMessage = DatabaseComponent.get(this).lokiMessageDatabase().getErrorMessage(messageRecord!!.getId()) - if (errorMessage != null) { - binding.errorMessage.text = errorMessage - binding.resendContainer.isVisible = true - binding.errorContainer.isVisible = true - } else { - 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 + @Composable + private fun MessageDetailsScreen() { + val state by viewModel.stateFlow.collectAsState() + AppTheme { + MessageDetails( + state = state, + onReply = { setResultAndFinish(ON_REPLY) }, + onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } }, + onDelete = { setResultAndFinish(ON_DELETE) }, + onClickImage = { viewModel.onClickImage(it) }, + onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload, + ) } } -} \ No newline at end of file + + 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, 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, + 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) { + 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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt new file mode 100644 index 000000000..a73fe4113 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt @@ -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() + 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 + 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 = emptyList(), + val imageAttachments: List = attachments.filter { it.hasImage }, + val nonImageAttachmentFileDetails: List? = 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, + val fileName: String?, + val uri: Uri?, + val hasImage: Boolean +) + +sealed class Event { + object Finish: Event() + data class StartMediaPreview(val args: MediaPreviewArgs): Event() +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt index 834b77ecc..d54426391 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt @@ -28,11 +28,10 @@ class MentionCandidateView : LinearLayout { private fun update() = with(binding) { mentionCandidateNameTextView.text = mentionCandidate.displayName - profilePictureView.root.publicKey = mentionCandidate.publicKey - profilePictureView.root.displayName = mentionCandidate.displayName - profilePictureView.root.additionalPublicKey = null - profilePictureView.root.glide = glide!! - profilePictureView.root.update() + profilePictureView.publicKey = mentionCandidate.publicKey + profilePictureView.displayName = mentionCandidate.displayName + profilePictureView.additionalPublicKey = null + profilePictureView.update() if (openGroupServer != null && openGroupRoom != null) { val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", mentionCandidate.publicKey) moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt index bcabca98f..c0ff1cbb1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt @@ -1,51 +1,42 @@ package org.thoughtcrime.securesms.conversation.v2.dialogs +import android.app.Dialog import android.content.Context import android.graphics.Typeface +import android.os.Bundle import android.text.Spannable import android.text.SpannableStringBuilder import android.text.style.StyleSpan -import android.view.LayoutInflater -import androidx.appcompat.app.AlertDialog -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch +import androidx.fragment.app.DialogFragment 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.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.util.ConfigurationMessageUtilities /** 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) { - val binding = DialogBlockedBinding.inflate(LayoutInflater.from(requireContext())) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase() val sessionID = recipient.address.toString() val contact = contactDB.getContactWithSessionID(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 spannable = SpannableStringBuilder(explanation) val startIndex = explanation.indexOf(name) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - binding.blockedExplanationTextView.text = spannable - binding.cancelButton.setOnClickListener { dismiss() } - binding.unblockButton.setOnClickListener { unblock() } - builder.setView(binding.root) + + title(resources.getString(R.string.dialog_blocked_title, name)) + text(spannable) + button(R.string.ConversationActivity_unblock) { unblock() } + cancelButton { dismiss() } } private fun unblock() { - DatabaseComponent.get(requireContext()).recipientDatabase().setBlocked(recipient, false) + MessagingModuleConfiguration.shared.storage.setBlocked(listOf(recipient), false) dismiss() - - // TODO: Remove in UserConfig branch - GlobalScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) - } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt index 42cca1ad3..5edd63f10 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt @@ -1,19 +1,19 @@ package org.thoughtcrime.securesms.conversation.v2.dialogs +import android.app.Dialog import android.graphics.Typeface +import android.os.Bundle import android.text.Spannable import android.text.SpannableStringBuilder import android.text.style.StyleSpan -import android.view.LayoutInflater -import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R -import network.loki.messenger.databinding.DialogDownloadBinding import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.JobQueue 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.dependencies.DatabaseComponent 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 * they are to be trusted and files sent by them are to be downloaded. */ @AndroidEntryPoint -class DownloadDialog(private val recipient: Recipient) : BaseDialog() { +class DownloadDialog(private val recipient: Recipient) : DialogFragment() { @Inject lateinit var contactDB: SessionContactDatabase - override fun setContentView(builder: AlertDialog.Builder) { - val binding = DialogDownloadBinding.inflate(LayoutInflater.from(requireContext())) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { val sessionID = recipient.address.toString() val contact = contactDB.getContactWithSessionID(sessionID) val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID - val title = resources.getString(R.string.dialog_download_title, name) - binding.downloadTitleTextView.text = title + title(resources.getString(R.string.dialog_download_title, name)) + val explanation = resources.getString(R.string.dialog_download_explanation, name) val spannable = SpannableStringBuilder(explanation) val startIndex = explanation.indexOf(name) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - binding.downloadExplanationTextView.text = spannable - binding.cancelButton.setOnClickListener { dismiss() } - binding.downloadButton.setOnClickListener { trust() } - builder.setView(binding.root) + text(spannable) + + button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) { trust() } + cancelButton { dismiss() } } private fun trust() { @@ -50,4 +49,4 @@ class DownloadDialog(private val recipient: Recipient) : BaseDialog() { JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY) dismiss() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt index 444c389e0..a886e8919 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt @@ -1,46 +1,42 @@ package org.thoughtcrime.securesms.conversation.v2.dialogs +import android.app.Dialog import android.graphics.Typeface +import android.os.Bundle import android.text.Spannable import android.text.SpannableStringBuilder import android.text.style.StyleSpan -import android.view.LayoutInflater import android.widget.Toast -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.DialogFragment import network.loki.messenger.R -import network.loki.messenger.databinding.DialogJoinOpenGroupBinding import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.utilities.OpenGroupUrlParser 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.util.ConfigurationMessageUtilities /** 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) { - val binding = DialogJoinOpenGroupBinding.inflate(LayoutInflater.from(requireContext())) - val title = resources.getString(R.string.dialog_join_open_group_title, name) - binding.joinOpenGroupTitleTextView.text = title + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { + title(resources.getString(R.string.dialog_join_open_group_title, name)) val explanation = resources.getString(R.string.dialog_join_open_group_explanation, name) val spannable = SpannableStringBuilder(explanation) val startIndex = explanation.indexOf(name) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - binding.joinOpenGroupExplanationTextView.text = spannable - binding.cancelButton.setOnClickListener { dismiss() } - binding.joinButton.setOnClickListener { join() } - builder.setView(binding.root) + text(spannable) + cancelButton { dismiss() } + button(R.string.open_group_invitation_view__join_accessibility_description) { join() } } private fun join() { val openGroup = OpenGroupUrlParser.parseUrl(url) - val activity = requireContext() as AppCompatActivity + val activity = requireActivity() ThreadUtils.queue { try { - OpenGroupManager.add(openGroup.server, openGroup.room, openGroup.serverPublicKey, activity) - MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server) + openGroup.apply { OpenGroupManager.add(server, room, serverPublicKey, activity) } + MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server, openGroup.room) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity) } catch (e: Exception) { 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() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt index a16ca86f7..996dd41f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt @@ -1,20 +1,21 @@ package org.thoughtcrime.securesms.conversation.v2.dialogs -import android.view.LayoutInflater -import androidx.appcompat.app.AlertDialog -import network.loki.messenger.databinding.DialogLinkPreviewBinding +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import network.loki.messenger.R 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 * 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) { - val binding = DialogLinkPreviewBinding.inflate(LayoutInflater.from(requireContext())) - binding.cancelButton.setOnClickListener { dismiss() } - binding.enableLinkPreviewsButton.setOnClickListener { enable() } - builder.setView(binding.root) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { + title(R.string.dialog_link_preview_title) + text(R.string.dialog_link_preview_explanation) + button(R.string.dialog_link_preview_enable_button_title) { enable() } + cancelButton { dismiss() } } private fun enable() { @@ -22,4 +23,4 @@ class LinkPreviewDialog(private val onEnabled: () -> Unit) : BaseDialog() { dismiss() onEnabled() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt index f51261d49..6abb0814d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt @@ -1,22 +1,23 @@ package org.thoughtcrime.securesms.conversation.v2.dialogs -import android.view.LayoutInflater -import androidx.appcompat.app.AlertDialog -import network.loki.messenger.databinding.DialogSendSeedBinding -import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import network.loki.messenger.R +import org.thoughtcrime.securesms.createSessionDialog /** 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) { - val binding = DialogSendSeedBinding.inflate(LayoutInflater.from(requireContext())) - binding.cancelButton.setOnClickListener { dismiss() } - binding.sendSeedButton.setOnClickListener { send() } - builder.setView(binding.root) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { + title(R.string.dialog_send_seed_title) + text(R.string.dialog_send_seed_explanation) + button(R.string.dialog_send_seed_send_button_title) { send() } + cancelButton() } private fun send() { proceed?.invoke() dismiss() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt index a21ba1b50..2d8f74596 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt @@ -28,11 +28,10 @@ class MentionCandidateView : RelativeLayout { private fun update() = with(binding) { mentionCandidateNameTextView.text = candidate.displayName - profilePictureView.root.publicKey = candidate.publicKey - profilePictureView.root.displayName = candidate.displayName - profilePictureView.root.additionalPublicKey = null - profilePictureView.root.glide = glide!! - profilePictureView.root.update() + profilePictureView.publicKey = candidate.publicKey + profilePictureView.displayName = candidate.displayName + profilePictureView.additionalPublicKey = null + profilePictureView.update() if (openGroupServer != null && openGroupRoom != null) { val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey) moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index f86920f90..3746aa52e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -67,7 +67,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p menu.findItem(R.id.menu_context_copy_public_key).isVisible = (thread.isGroupRecipient && !thread.isOpenGroupRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey) // 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 menu.findItem(R.id.menu_context_resend).isVisible = (selectedItems.size == 1 && firstMessage.isFailed) // Resync diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index ce29efa3a..02ee4ae45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.conversation.v2.menus import android.annotation.SuppressLint import android.content.Context -import android.content.DialogInterface import android.content.Intent import android.graphics.BitmapFactory import android.graphics.PorterDuff @@ -15,7 +14,6 @@ import android.widget.ImageView import android.widget.TextView import android.widget.Toast import androidx.annotation.ColorInt -import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ContextThemeWrapper 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.toHexString import org.thoughtcrime.securesms.MediaOverviewActivity -import org.thoughtcrime.securesms.MuteDialog import org.thoughtcrime.securesms.ShortcutLauncherActivity import org.thoughtcrime.securesms.calls.WebRtcCallActivity 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.preferences.PrivacySettingsActivity import org.thoughtcrime.securesms.service.WebRtcCallService +import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.showMuteDialog import org.thoughtcrime.securesms.util.BitmapUtil import java.io.IOException @@ -64,17 +63,18 @@ object ConversationMenuHelper { // Base menu (options that should always be present) inflater.inflate(R.menu.menu_conversation, menu) // Expiring messages - if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient)) { + if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient) && !thread.isBlocked) { if (thread.expireMessages > 0) { inflater.inflate(R.menu.menu_conversation_expiration_on, menu) val item = menu.findItem(R.id.menu_expiring_messages) - val actionView = item.actionView - val iconView = actionView.findViewById(R.id.menu_badge_icon) - val badgeView = actionView.findViewById(R.id.expiration_badge) - @ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary) - iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY) - badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages) - actionView.setOnClickListener { onOptionsItemSelected(item) } + item.actionView?.let { actionView -> + val iconView = actionView.findViewById(R.id.menu_badge_icon) + val badgeView = actionView.findViewById(R.id.expiration_badge) + @ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary) + iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY) + badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages) + actionView.setOnClickListener { onOptionsItemSelected(item) } + } } else { inflater.inflate(R.menu.menu_conversation_expiration_off, menu) } @@ -87,7 +87,7 @@ object ConversationMenuHelper { if (thread.isContactRecipient) { if (thread.isBlocked) { inflater.inflate(R.menu.menu_conversation_unblock, menu) - } else { + } else if (!thread.isLocalNumber) { inflater.inflate(R.menu.menu_conversation_block, menu) } } @@ -186,29 +186,23 @@ object ConversationMenuHelper { private fun call(context: Context, thread: Recipient) { if (!TextSecurePreferences.isCallNotificationsEnabled(context)) { - val dialog = AlertDialog.Builder(context) - .setTitle(R.string.ConversationActivity_call_title) - .setMessage(R.string.ConversationActivity_call_prompt) - .setPositiveButton(R.string.activity_settings_title) { _, _ -> - val intent = Intent(context, PrivacySettingsActivity::class.java) - context.startActivity(intent) + context.showSessionDialog { + title(R.string.ConversationActivity_call_title) + text(R.string.ConversationActivity_call_prompt) + button(R.string.activity_settings_title, R.string.AccessibilityId_settings) { + Intent(context, PrivacySettingsActivity::class.java).let(context::startActivity) } - .setNeutralButton(R.string.cancel) { d, _ -> - 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() + cancelButton() + } return } - val service = WebRtcCallService.createCall(context, thread) - context.startService(service) + WebRtcCallService.createCall(context, thread) + .let(context::startService) - val activity = Intent(context, WebRtcCallActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - context.startActivity(activity) + Intent(context, WebRtcCallActivity::class.java) + .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } + .let(context::startActivity) } @@ -295,9 +289,7 @@ object ConversationMenuHelper { private fun leaveClosedGroup(context: Context, thread: Recipient) { 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 admins = group.admins val sessionID = TextSecurePreferences.getLocalNumber(context) @@ -307,29 +299,25 @@ object ConversationMenuHelper { } else { context.resources.getString(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group) } - builder.setMessage(message) - builder.setPositiveButton(R.string.yes) { _, _ -> - var groupPublicKey: String? - var isClosedGroup: Boolean - try { - groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() - isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) - } catch (e: IOException) { - groupPublicKey = null - isClosedGroup = false - } - try { - if (isClosedGroup) { - MessageSender.leave(groupPublicKey!!, true) - } else { - Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show() + + fun onLeaveFailed() = Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show() + + context.showSessionDialog { + title(R.string.ConversationActivity_leave_group) + text(message) + button(R.string.yes) { + try { + val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() + val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) + + if (isClosedGroup) MessageSender.leave(groupPublicKey, notifyUser = false) + else onLeaveFailed() + } catch (e: Exception) { + onLeaveFailed() } - } 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) { @@ -344,7 +332,7 @@ object ConversationMenuHelper { } 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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 75a3c5875..c812d0f73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.graphics.Color import android.graphics.Rect -import android.graphics.drawable.Drawable import android.text.Spannable import android.text.style.BackgroundColorSpan import android.text.style.ForegroundColorSpan @@ -15,9 +14,7 @@ import android.view.View import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.BlendModeColorFilterCompat -import androidx.core.graphics.BlendModeCompat +import androidx.core.graphics.ColorUtils import androidx.core.text.getSpans import androidx.core.text.toSpannable import androidx.core.view.children @@ -28,6 +25,7 @@ import okhttp3.HttpUrl import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress 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.recipients.Recipient 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.MmsMessageRecord import org.thoughtcrime.securesms.database.model.SmsMessageRecord +import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.getAccentColor -import java.util.* +import java.util.Locale import kotlin.math.roundToInt class VisibleMessageContentView : ConstraintLayout { private val binding: ViewVisibleMessageContentBinding by lazy { ViewVisibleMessageContentBinding.bind(this) } - var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() var onContentDoubleTap: (() -> Unit)? = null var delegate: VisibleMessageViewDelegate? = null var indexInAdapter: Int = -1 @@ -60,21 +59,20 @@ class VisibleMessageContentView : ConstraintLayout { // region Updating fun bind( message: MessageRecord, - isStartOfMessageCluster: Boolean, - isEndOfMessageCluster: Boolean, - glide: GlideRequests, + isStartOfMessageCluster: Boolean = true, + isEndOfMessageCluster: Boolean = true, + glide: GlideRequests = GlideApp.with(this), thread: Recipient, - searchQuery: String?, - contactIsTrusted: Boolean, - onAttachmentNeedsDownload: (Long, Long) -> Unit + searchQuery: String? = null, + contactIsTrusted: Boolean = true, + onAttachmentNeedsDownload: (Long, Long) -> Unit, + suppressThumbnails: Boolean = false ) { // Background - val background = getBackground(message.isOutgoing) val color = if (message.isOutgoing) context.getAccentColor() else context.getColorFromAttr(R.attr.message_received_background_color) - val filter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN) - background.colorFilter = filter - binding.contentParent.background = background + binding.contentParent.mainColor = color + binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius) val onlyBodyMessage = message is SmsMessageRecord val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null @@ -131,7 +129,6 @@ class VisibleMessageContentView : ConstraintLayout { delegate?.scrollToMessageIfPossible(quote.id) } } - val hasMedia = message.slideDeck.asAttachments().isNotEmpty() } if (message is MmsMessageRecord) { @@ -188,7 +185,7 @@ class VisibleMessageContentView : ConstraintLayout { 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 */ @@ -241,14 +238,15 @@ class VisibleMessageContentView : ConstraintLayout { 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 = listOf(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() { arrayOf( binding.deletedMessageView.root, @@ -266,6 +264,15 @@ class VisibleMessageContentView : ConstraintLayout { fun playVoiceMessage() { 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 // region Convenience diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 319140731..9538148fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -2,16 +2,17 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.content.Intent -import android.content.res.Resources import android.graphics.Canvas import android.graphics.Rect import android.graphics.drawable.ColorDrawable import android.os.Handler import android.os.Looper import android.util.AttributeSet +import android.view.Gravity import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.view.View +import android.widget.FrameLayout import android.widget.LinearLayout import androidx.annotation.ColorInt 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.groups.OpenGroupManager import org.thoughtcrime.securesms.home.UserDetailsBottomSheet +import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.disableClipping @@ -70,7 +72,6 @@ class VisibleMessageView : LinearLayout { @Inject lateinit var mmsDb: MmsDatabase 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 swipeToReplyIconRect = Rect() private var dx = 0.0f @@ -111,7 +112,10 @@ class VisibleMessageView : LinearLayout { private fun initialize() { isHapticFeedbackEnabled = true setWillNotDraw(false) + binding.root.disableClipping() + binding.mainContainer.disableClipping() binding.messageInnerContainer.disableClipping() + binding.messageInnerLayout.disableClipping() binding.messageContentView.root.disableClipping() } // endregion @@ -119,13 +123,14 @@ class VisibleMessageView : LinearLayout { // region Updating fun bind( message: MessageRecord, - previous: MessageRecord?, - next: MessageRecord?, - glide: GlideRequests, - searchQuery: String?, - contact: Contact?, + previous: MessageRecord? = null, + next: MessageRecord? = null, + glide: GlideRequests = GlideApp.with(this), + searchQuery: String? = null, + contact: Contact? = null, senderSessionID: String, - delegate: VisibleMessageViewDelegate?, + lastSeen: Long, + delegate: VisibleMessageViewDelegate? = null, onAttachmentNeedsDownload: (Long, Long) -> Unit ) { val threadID = message.threadId @@ -136,7 +141,7 @@ class VisibleMessageView : LinearLayout { // Show profile picture and sender name if this is a group thread AND // the message is incoming binding.moderatorIconImageView.isVisible = false - binding.profilePictureView.root.visibility = when { + binding.profilePictureView.visibility = when { thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE thread.isGroupRecipient -> View.INVISIBLE else -> View.GONE @@ -145,25 +150,25 @@ class VisibleMessageView : LinearLayout { val bottomMargin = if (isEndOfMessageCluster) resources.getDimensionPixelSize(R.dimen.small_spacing) 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 expirationParams.bottomMargin = bottomMargin binding.messageInnerContainer.layoutParams = expirationParams } else { - val avatarLayoutParams = binding.profilePictureView.root.layoutParams as MarginLayoutParams + val avatarLayoutParams = binding.profilePictureView.layoutParams as MarginLayoutParams avatarLayoutParams.bottomMargin = bottomMargin - binding.profilePictureView.root.layoutParams = avatarLayoutParams + binding.profilePictureView.layoutParams = avatarLayoutParams } if (isGroupThread && !message.isOutgoing) { if (isEndOfMessageCluster) { - binding.profilePictureView.root.publicKey = senderSessionID - binding.profilePictureView.root.glide = glide - binding.profilePictureView.root.update(message.individualRecipient) - binding.profilePictureView.root.setOnClickListener { + binding.profilePictureView.publicKey = senderSessionID + binding.profilePictureView.update(message.individualRecipient) + binding.profilePictureView.setOnClickListener { if (thread.isOpenGroupRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) { + // TODO: support v2 soon val intent = Intent(context, ConversationActivityV2::class.java) intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID) intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderSessionID)) @@ -177,7 +182,7 @@ class VisibleMessageView : LinearLayout { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return var standardPublicKey = "" var blindedPublicKey: String? = null - if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED) { + if (IdPrefix.fromValue(senderSessionID)?.isBlinded() == true) { blindedPublicKey = senderSessionID } else { standardPublicKey = senderSessionID @@ -191,6 +196,8 @@ class VisibleMessageView : LinearLayout { val contactContext = if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR 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 val showDateBreak = isStartOfMessageCluster || snIsSelected 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) { val container = binding.messageInnerContainer - val content = binding.messageContentView.root - val expiration = binding.expirationTimerView - container.removeAllViewsInLayout() - container.addView(if (message.isOutgoing) expiration else content) - container.addView(if (message.isOutgoing) content else expiration) + val layout = binding.messageInnerLayout + + if (message.isOutgoing) binding.messageContentView.root.bringToFront() + else binding.expirationTimerView.bringToFront() + + 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 containerParams.horizontalBias = if (message.isOutgoing) 1f else 0f container.layoutParams = containerParams @@ -386,7 +396,7 @@ class VisibleMessageView : LinearLayout { val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing) val iconSize = toPx(24, context.resources) 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 bottom = top + iconSize swipeToReplyIconRect.left = left @@ -406,9 +416,13 @@ class VisibleMessageView : LinearLayout { } fun recycle() { - binding.profilePictureView.root.recycle() + binding.profilePictureView.recycle() binding.messageContentView.root.recycle() } + + fun playHighlight() { + binding.messageContentView.root.playHighlight() + } // endregion // region Interaction @@ -503,7 +517,7 @@ class VisibleMessageView : LinearLayout { } fun onContentClick(event: MotionEvent) { - binding.messageContentView.root.onContentClick.iterator().forEach { clickHandler -> clickHandler.invoke(event) } + binding.messageContentView.root.onContentClick(event) } private fun onPress(event: MotionEvent) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt index e1bf92c5f..2b829af15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt @@ -92,7 +92,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener { if (progress == 1.0) { togglePlayback() handleProgressChanged(0.0) - delegate?.playVoiceMessageAtIndexIfPossible(indexInAdapter - 1) + delegate?.playVoiceMessageAtIndexIfPossible(indexInAdapter + 1) } else { handleProgressChanged(progress) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java index dd90b699e..088685241 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java @@ -25,6 +25,7 @@ import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.os.AsyncTask; +import android.os.Build; import android.provider.OpenableColumns; import android.text.TextUtils; 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) { - Permissions.with(activity) - .request(Manifest.permission.READ_EXTERNAL_STORAGE) - .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(); + Permissions.PermissionsBuilder builder = Permissions.with(activity); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO) + .request(Manifest.permission.READ_MEDIA_IMAGES); + } else { + 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/BaseDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/BaseDialog.kt deleted file mode 100644 index c3a9689a0..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/BaseDialog.kt +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt index dbbcfb51e..c0ce83f63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt @@ -1,21 +1,18 @@ package org.thoughtcrime.securesms.conversation.v2.utilities import android.content.Context -import androidx.appcompat.app.AlertDialog import network.loki.messenger.R import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.showSessionDialog object NotificationUtils { fun showNotifyDialog(context: Context, thread: Recipient, notifyTypeHandler: (Int)->Unit) { - val notifyTypes = context.resources.getStringArray(R.array.notify_types) - val currentSelected = thread.notifyType - - AlertDialog.Builder(context) - .setSingleChoiceItems(notifyTypes,currentSelected) { d, newSelection -> - notifyTypeHandler(newSelection) - d.dismiss() - } - .setTitle(R.string.RecipientPreferenceActivity_notification_settings) - .show() + context.showSessionDialog { + title(R.string.RecipientPreferenceActivity_notification_settings) + singleChoiceItems( + context.resources.getStringArray(R.array.notify_types), + thread.notifyType + ) { notifyTypeHandler(it) } + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt new file mode 100644 index 000000000..19a511bfd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index 79adead57..66d01114e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -36,9 +36,9 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt @SuppressWarnings("unused") 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"; - 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 MEMBERS = "members"; private static final String ZOMBIE_MEMBERS = "zombie_members"; @@ -133,12 +133,12 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt return new Reader(cursor); } - public List getAllGroups() { + public List getAllGroups(boolean includeInactive) { Reader reader = getGroups(); GroupRecord record; List groups = new LinkedList<>(); while ((record = reader.getNext()) != null) { - if (record.isActive()) { groups.add(record); } + if (record.isActive() || includeInactive) { groups.add(record); } } reader.close(); return groups; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt index b0f6a676c..53f4ea319 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -458,9 +458,8 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( 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 timestamp = Date().time.toString() val index = "$groupPublicKey-$timestamp" val encryptionKeyPairPublicKey = encryptionKeyPair.publicKey.serialize().toHexString().removingIdPrefixIfNeeded() val encryptionKeyPairPrivateKey = encryptionKeyPair.privateKey.serialize().toHexString() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt index 300217fab..1cbbf34c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt @@ -4,11 +4,8 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor 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.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper -import org.thoughtcrime.securesms.dependencies.DatabaseComponent 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);" } - 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 { val database = databaseHelper.readableDatabase 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) { if (threadID < 0) { return diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index e8f65dae0..111b6d536 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -20,13 +20,11 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import com.annimon.stream.Stream -import com.google.android.mms.pdu_alt.NotificationInd import com.google.android.mms.pdu_alt.PduHeaders import org.json.JSONArray import org.json.JSONException import org.json.JSONObject 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.OutgoingMediaMessage 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.fromSerialized import org.session.libsession.utilities.Contact -import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID import org.session.libsession.utilities.IdentityKeyMismatch import org.session.libsession.utilities.IdentityKeyMismatchList import org.session.libsession.utilities.NetworkFailure import org.session.libsession.utilities.NetworkFailureList import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled 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.RecipientFormattingException import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils.queue @@ -162,7 +157,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) get(context).groupReceiptDatabase() .update(ourAddress, id, status, timestamp) - get(context).threadDatabase().update(threadId, false) + get(context).threadDatabase().update(threadId, false, true) 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?): Cursor { val database = databaseHelper.readableDatabase return database.rawQuery( @@ -259,7 +235,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa " WHERE " + ID + " = ?", arrayOf(id.toString() + "") ) 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() queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(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) } @@ -343,6 +316,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(id.toString())) } + fun setMessagesRead(threadId: Long, beforeTime: Long): List { + return setMessagesRead( + THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1) AND " + DATE_SENT + " <= ?", + arrayOf(threadId.toString(), beforeTime.toString()) + ) + } + fun setMessagesRead(threadId: Long): List { return setMessagesRead( THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", @@ -567,18 +547,9 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa contentLocation: String, threadId: Long, mailbox: Long, serverTimestamp: Long, - runIncrement: Boolean, runThreadUpdate: Boolean ): Optional { - var threadId = threadId - if (threadId == -1L || retrieved.isGroupMessage) { - try { - threadId = getThreadIdFor(retrieved) - } catch (e: RecipientFormattingException) { - Log.w("MmsDatabase", e) - if (threadId == -1L) throw MmsException(e) - } - } + if (threadId < 0 ) throw MmsException("No thread ID supplied!") val contentValues = ContentValues() contentValues.put(DATE_SENT, retrieved.sentTimeMillis) contentValues.put(ADDRESS, retrieved.from.serialize()) @@ -632,12 +603,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa null, ) if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) { - if (runIncrement) { - val mentionAmount = if (retrieved.hasMention()) { 1 } else { 0 } - get(context).threadDatabase().incrementUnread(threadId, 1, mentionAmount) - } if (runThreadUpdate) { - get(context).threadDatabase().update(threadId, true) + get(context).threadDatabase().update(threadId, true, true) } } notifyConversationListeners(threadId) @@ -651,27 +618,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa serverTimestamp: Long, runThreadUpdate: Boolean ): Optional { - var threadId = threadId - 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) - } - } + if (threadId < 0 ) throw MmsException("No thread ID supplied!") val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate) if (messageId == -1L) { return Optional.absent() @@ -686,7 +633,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa retrieved: IncomingMediaMessage, threadId: Long, serverTimestamp: Long = 0, - runIncrement: Boolean, runThreadUpdate: Boolean ): Optional { 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) { 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 @@ -794,10 +740,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) } with (get(context).threadDatabase()) { - setLastSeen(threadId) + val lastSeen = getLastSeenAndHasSent(threadId).first() + if (lastSeen < message.sentTimeMillis) { + setLastSeen(threadId, message.sentTimeMillis) + } setHasSent(threadId, true) if (runThreadUpdate) { - update(threadId, true) + update(threadId, true, true) } } return messageId @@ -932,7 +881,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa groupReceiptDatabase.deleteRowsForMessage(messageId) val database = databaseHelper.writableDatabase 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) notifyStickerListeners() notifyStickerPackListeners() @@ -949,7 +898,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val database = databaseHelper.writableDatabase 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) notifyStickerListeners() notifyStickerPackListeners() @@ -1147,7 +1096,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } val threadDb = get(context).threadDatabase() for (threadId in threadIds) { - val threadDeleted = threadDb.update(threadId, false) + val threadDeleted = threadDb.update(threadId, false, true) notifyConversationListeners(threadId) } notifyStickerListeners() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index c7f9d6132..0db4dd00e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -16,6 +16,8 @@ */ package org.thoughtcrime.securesms.database; +import static org.thoughtcrime.securesms.database.MmsDatabase.MESSAGE_BOX; + import android.content.Context; import android.database.Cursor; @@ -25,6 +27,7 @@ import androidx.annotation.Nullable; import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteQueryBuilder; +import org.jetbrains.annotations.NotNull; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Util; import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; @@ -36,6 +39,8 @@ import java.io.Closeable; import java.util.HashSet; import java.util.Set; +import kotlin.Pair; + public class MmsSmsDatabase extends Database { @SuppressWarnings("unused") @@ -259,8 +264,8 @@ public class MmsSmsDatabase extends Database { return -1; } - public int getMessagePositionInConversation(long threadId, long sentTimestamp, @NonNull Address address) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; + public int getMessagePositionInConversation(long threadId, long sentTimestamp, @NonNull Address address, boolean reverse) { + String order = MmsSmsColumns.NORMALIZED_DATE_SENT + (reverse ? " DESC" : " ASC"); String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; 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); } + @NotNull + public Pair 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(MmsSmsColumns.Types.isOutgoingMessageType(type), sentTime); + } + public class Reader implements Closeable { private final Cursor cursor; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index e3570fd28..b7b836418 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -62,13 +62,14 @@ public class RecipientDatabase extends Database { private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode"; 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 WRAPPER_HASH = "wrapper_hash"; 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, PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI, SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL, UNIDENTIFIED_ACCESS_MODE, - FORCE_SMS_SELECTION, NOTIFY_TYPE, + FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH }; static final List 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+")))"; } + 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_MENTIONS = 1; public static final int NOTIFY_TYPE_NONE = 2; @@ -154,18 +160,14 @@ public class RecipientDatabase extends Database { public Optional getRecipientSettings(@NonNull Address address) { SQLiteDatabase database = databaseHelper.getReadableDatabase(); - Cursor cursor = null; - try { - cursor = database.query(TABLE_NAME, null, ADDRESS + " = ?", new String[] {address.serialize()}, null, null, null); + try (Cursor cursor = database.query(TABLE_NAME, null, ADDRESS + " = ?", new String[]{address.serialize()}, null, null, null)) { if (cursor != null && cursor.moveToNext()) { return getRecipientSettings(cursor); } 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)); int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE)); boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1; + String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH)); MaterialColor color; byte[] profileKey = null; @@ -225,7 +228,7 @@ public class RecipientDatabase extends Database { systemPhoneLabel, systemContactUri, signalProfileName, signalProfileAvatar, profileSharing, notificationChannel, Recipient.UnidentifiedAccessMode.fromMode(unidentifiedAccessMode), - forceSmsSelection)); + forceSmsSelection, wrapperHash)); } public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) { @@ -252,6 +255,24 @@ public class RecipientDatabase extends Database { 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) { ContentValues values = new ContentValues(); values.put(APPROVED, approved ? 1 : 0); @@ -268,14 +289,6 @@ public class RecipientDatabase extends Database { 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 recipients, boolean blocked) { SQLiteDatabase db = getWritableDatabase(); db.beginTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt index 40eee9742..49a633936 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -2,10 +2,12 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context -import androidx.core.database.getStringOrNull import android.database.Cursor +import androidx.core.database.getStringOrNull 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.IdPrefix import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { @@ -43,6 +45,9 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da val database = databaseHelper.readableDatabase return database.getAll(sessionContactTable, null, null) { cursor -> contactFromCursor(cursor) + }.filter { contact -> + val sessionId = SessionId(contact.sessionID) + sessionId.prefix == IdPrefix.STANDARD }.toSet() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt index b081fb007..6221446aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt @@ -93,6 +93,7 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa fun cancelPendingMessageSendJobs(threadID: Long) { val database = databaseHelper.writableDatabase val attachmentUploadJobKeys = mutableListOf() + database.beginTransaction() database.getAll(sessionJobTable, "$jobType = ?", arrayOf( AttachmentUploadJob.KEY )) { cursor -> val job = jobFromCursor(cursor) as AttachmentUploadJob? 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 (attachmentUploadJobKeys.isNotEmpty()) { - val attachmentUploadJobKeysAsString = attachmentUploadJobKeys.joinToString(", ") - database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} IN (?)", - arrayOf( AttachmentUploadJob.KEY, attachmentUploadJobKeysAsString )) + attachmentUploadJobKeys.forEach { + database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} = ?", + arrayOf( AttachmentUploadJob.KEY, it )) + } } if (messageSendJobKeys.isNotEmpty()) { - val messageSendJobKeysAsString = messageSendJobKeys.joinToString(", ") - database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} IN (?)", - arrayOf( MessageSendJob.KEY, messageSendJobKeysAsString )) + messageSendJobKeys.forEach { + database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} = ?", + arrayOf( MessageSendJob.KEY, it )) + } } + database.setTransactionSuccessful() + database.endTransaction() } fun isJobCanceled(job: Job): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 42a00ccbb..4ef576f40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -148,7 +148,7 @@ public class SmsDatabase extends MessagingDatabase { long threadId = getThreadIdForMessage(id); - DatabaseComponent.get(context).threadDatabase().update(threadId, false); + DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); notifyConversationListeners(threadId); } @@ -234,10 +234,6 @@ public class SmsDatabase extends MessagingDatabase { contentValues.put(BODY, ""); contentValues.put(HAS_MENTION, 0); 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); } @@ -256,7 +252,7 @@ public class SmsDatabase extends MessagingDatabase { long threadId = getThreadIdForMessage(id); - DatabaseComponent.get(context).threadDatabase().update(threadId, false); + DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); notifyConversationListeners(threadId); } @@ -319,7 +315,7 @@ public class SmsDatabase extends MessagingDatabase { 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); foundMessage = true; } @@ -337,6 +333,9 @@ public class SmsDatabase extends MessagingDatabase { } } + public List 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 setMessagesRead(long 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); - DatabaseComponent.get(context).threadDatabase().update(threadId, true); + DatabaseComponent.get(context).threadDatabase().update(threadId, true, true); notifyConversationListeners(threadId); notifyConversationListListeners(); return new Pair<>(messageId, threadId); } - protected Optional insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) { + protected Optional insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runThreadUpdate) { if (message.isSecureMessage()) { type |= Types.SECURE_MESSAGE_BIT; } else if (message.isGroup()) { @@ -486,12 +485,8 @@ public class SmsDatabase extends MessagingDatabase { SQLiteDatabase db = databaseHelper.getWritableDatabase(); long messageId = db.insert(TABLE_NAME, null, values); - if (unread && runIncrement) { - DatabaseComponent.get(context).threadDatabase().incrementUnread(threadId, 1, (message.hasMention() ? 1 : 0)); - } - if (runThreadUpdate) { - DatabaseComponent.get(context).threadDatabase().update(threadId, true); + DatabaseComponent.get(context).threadDatabase().update(threadId, true, true); } if (message.getSubscriptionId() != -1) { @@ -504,16 +499,16 @@ public class SmsDatabase extends MessagingDatabase { } } - public Optional insertMessageInbox(IncomingTextMessage message, boolean runIncrement, boolean runThreadUpdate) { - return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runIncrement, runThreadUpdate); + public Optional insertMessageInbox(IncomingTextMessage message, boolean runThreadUpdate) { + return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runThreadUpdate); } public Optional insertCallMessage(IncomingTextMessage message) { - return insertMessageInbox(message, 0, 0, true, true); + return insertMessageInbox(message, 0, 0, true); } - public Optional insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) { - return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runIncrement, runThreadUpdate); + public Optional insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runThreadUpdate) { + return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runThreadUpdate); } public Optional insertMessageOutbox(long threadId, OutgoingTextMessage message, long serverTimestamp, boolean runThreadUpdate) { @@ -567,9 +562,12 @@ public class SmsDatabase extends MessagingDatabase { } 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); @@ -616,7 +614,7 @@ public class SmsDatabase extends MessagingDatabase { SQLiteDatabase db = databaseHelper.getWritableDatabase(); long threadId = getThreadIdForMessage(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); return threadDeleted; } @@ -640,7 +638,7 @@ public class SmsDatabase extends MessagingDatabase { ID + " IN (" + StringUtils.join(argsArray, ',') + ")", argValues ); - boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false); + boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); notifyConversationListeners(threadId); return threadDeleted; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 365c12b83..c77ad1c63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -2,16 +2,43 @@ package org.thoughtcrime.securesms.database import android.content.Context 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.database.StorageProtocol import org.session.libsession.messaging.BlindedIdMapping import org.session.libsession.messaging.calls.CallMessageType 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.control.ConfigurationMessage 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.Profile 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.data_extraction.DataExtractionNotificationInfoMessage 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.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.UpdateMessageData 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.GroupRecord 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.TextSecurePreferences 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.messages.SignalServiceAttachmentPointer 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.KeyHelper +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory 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.session.libsession.messaging.jobs.RetrieveProfileAvatarJob import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.SessionMetaProtocol 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? { return TextSecurePreferences.getLocalNumber(context) } @@ -74,6 +184,25 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, 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 { var registrationID = TextSecurePreferences.getLocalRegistrationId(context) if (registrationID == 0) { @@ -94,19 +223,56 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return database.getAttachmentsForMessage(messageID) } - override fun markConversationAsRead(threadId: Long, updateLastSeen: Boolean) { + override fun getLastSeen(threadId: Long): Long { 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() - 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) { val threadDb = DatabaseComponent.get(context).threadDatabase() - threadDb.update(threadId, unarchive) + threadDb.update(threadId, unarchive, false) } override fun persist(message: VisibleMessage, @@ -115,7 +281,6 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, groupPublicKey: String?, openGroupID: String?, attachments: List, - runIncrement: Boolean, runThreadUpdate: Boolean): Long? { var messageID: Long? = null val senderAddress = fromSerialized(message.sender!!) @@ -142,13 +307,16 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } val targetRecipient = Recipient.from(context, targetAddress, false) if (!targetRecipient.isGroupRecipient) { - val recipientDb = DatabaseComponent.get(context).recipientDatabase() if (isUserSender || isUserBlindedSender) { - recipientDb.setApproved(targetRecipient, true) + setRecipientApproved(targetRecipient, true) } 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()) { val quote: Optional = if (quotes != null) Optional.of(quotes) else Optional.absent() val linkPreviews: Optional> = 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() } 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) { 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) else IncomingTextMessage.from(message, senderAddress, group, targetRecipient.expireMessages * 1000L) 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 -> messageID = result.messageId @@ -225,6 +393,12 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, 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) { val job = DatabaseComponent.get(context).sessionJobDatabase().getMessageSendJob(messageSendJobID) ?: return JobQueue.shared.resumePendingSendMessage(job) @@ -234,11 +408,201 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, 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? { val id = "$server.$room" 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 = 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) { val id = "$server.$room" 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) } + override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map, 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 { 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 infoMessage = IncomingGroupMessage(m, groupID, updateData, true) 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, admins: Collection, threadID: Long, sentTimestamp: Long) { @@ -552,8 +969,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseComponent.get(context).lokiAPIDatabase().removeClosedGroupPublicKey(groupPublicKey) } - override fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) { - DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey) + override fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) { + DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, timestamp) } override fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) { @@ -570,9 +987,21 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, .updateTimestampUpdated(groupID, updatedTimestamp) } - override fun setExpirationTimer(groupID: String, duration: Int) { - val recipient = Recipient.from(context, fromSerialized(groupID), false) - DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); + override fun setExpirationTimer(address: String, duration: Int) { + val recipient = Recipient.from(context, fromSerialized(address), false) + 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) { @@ -591,16 +1020,29 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, OpenGroupManager.updateOpenGroup(openGroup, context) } - override fun getAllGroups(): List { - return DatabaseComponent.get(context).groupDatabase().allGroups + override fun getAllGroups(includeInactive: Boolean): List { + return DatabaseComponent.get(context).groupDatabase().getAllGroups(includeInactive) } override fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? { return OpenGroupManager.addOpenGroup(urlAsString, context) } - override fun onOpenGroupAdded(server: String) { + override fun onOpenGroupAdded(server: String, room: String) { 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 { @@ -618,17 +1060,19 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, 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() return if (!openGroupID.isNullOrEmpty()) { 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()) { 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 { 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) } + override fun getThreadId(openGroup: OpenGroup): Long? { + return GroupManager.getOpenGroupThreadID("${openGroup.server.removeSuffix("/")}.${openGroup.room}", context) + } + override fun getThreadId(address: Address): Long? { val recipient = Recipient.from(context, address, false) return getThreadId(recipient) @@ -666,6 +1114,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, override fun setContact(contact: 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? { @@ -677,6 +1130,51 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return if (recipientSettings.isPresent) { recipientSettings.get() } else null } + override fun addLibSessionContacts(contacts: List) { + 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) { val recipientDatabase = DatabaseComponent.get(context).recipientDatabase() val threadDatabase = DatabaseComponent.get(context).threadDatabase() @@ -700,19 +1198,18 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, recipientDatabase.setProfileSharing(recipient, true) recipientDatabase.setRegistered(recipient, Recipient.RegisteredState.REGISTERED) // create Thread if needed - val threadId = threadDatabase.getOrCreateThreadIdFor(recipient) + val threadId = threadDatabase.getThreadIdIfExistsFor(recipient) if (contact.didApproveMe == true) { recipientDatabase.setApprovedMe(recipient, true) } - if (contact.isApproved == true) { - recipientDatabase.setApproved(recipient, true) + if (contact.isApproved == true && threadId != -1L) { + setRecipientApproved(recipient, true) threadDatabase.setHasSent(threadId, true) } val contactIsBlocked: Boolean? = contact.isBlocked if (contactIsBlocked != null && recipient.isBlocked != contactIsBlocked) { - recipientDatabase.setBlocked(recipient, contactIsBlocked) - threadDatabase.deleteConversation(threadId) + setBlocked(listOf(recipient), contactIsBlocked, fromConfigUpdate = true) } } 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 { val threadDB = DatabaseComponent.get(context).threadDatabase() return threadDB.getLastUpdated(threadID) @@ -740,12 +1242,78 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return mmsSmsDb.getConversationCount(threadID) } - override fun deleteConversation(threadId: Long) { + override fun setPinned(threadID: Long, isPinned: Boolean) { 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 { return PartAuthority.getAttachmentDataUri(attachmentId) @@ -762,6 +1330,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, if (recipient.isBlocked) return + val threadId = getThreadId(recipient) ?: return + val mediaMessage = IncomingMediaMessage( address, sentTimestamp, @@ -780,14 +1350,21 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, Optional.of(message) ) - database.insertSecureDecryptedMessageInbox(mediaMessage, -1, runIncrement = true, runThreadUpdate = true) + database.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) } override fun insertMessageRequestResponse(response: MessageRequestResponse) { val userPublicKey = getUserPublicKey() val senderPublicKey = response.sender!! 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 threadDB = DatabaseComponent.get(context).threadDatabase() if (userPublicKey == senderPublicKey) { @@ -799,7 +1376,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val mmsDb = DatabaseComponent.get(context).mmsDatabase() val smsDb = DatabaseComponent.get(context).smsDatabase() val sender = Recipient.from(context, fromSerialized(senderPublicKey), false) - val threadId = threadDB.getOrCreateThreadIdFor(sender) + val threadId = getOrCreateThreadIdFor(sender.address) val profile = response.profile if (profile != null) { 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)) 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.setProfilePictureURL(context, sender, profile.profilePictureURL!!) } } threadDB.setHasSent(threadId, true) @@ -873,16 +1449,28 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, 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) { 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) { 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) { @@ -1012,9 +1600,18 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms)) } - override fun unblock(toUnblock: Iterable) { + override fun setBlocked(recipients: Iterable, isBlocked: Boolean, fromConfigUpdate: Boolean) { 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 52d914af0..504452998 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -64,7 +64,6 @@ import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.util.SessionMetaProtocol; import java.io.Closeable; -import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; @@ -74,6 +73,11 @@ import java.util.Set; 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 final Map addressCache = new HashMap<>(); @@ -141,10 +145,16 @@ public class ThreadDatabase extends Database { "ADD COLUMN " + UNREAD_MENTION_COUNT + " INTEGER DEFAULT 0;"; } + private ConversationThreadUpdateListener updateListener; + public ThreadDatabase(Context context, SQLCipherOpenHelper databaseHelper) { super(context, databaseHelper); } + public void setUpdateListener(ConversationThreadUpdateListener updateListener) { + this.updateListener = updateListener; + } + private long createThreadForRecipient(Address address, boolean group, int distributionType) { ContentValues contentValues = new ContentValues(4); long date = SnodeAPI.getNowWithOffset(); @@ -207,10 +217,14 @@ public class ThreadDatabase extends Database { } private void deleteThread(long threadId) { + Recipient recipient = getRecipientForThreadId(threadId); 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); notifyConversationListListeners(); + if (updateListener != null && numberRemoved > 0 && recipient != null) { + updateListener.threadDeleted(recipient.getAddress(), threadId); + } } private void deleteThreads(Set threadIds) { @@ -278,7 +292,7 @@ public class ThreadDatabase extends Database { DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); - update(threadId, false); + update(threadId, false, true); notifyConversationListeners(threadId); } } finally { @@ -291,10 +305,34 @@ public class ThreadDatabase extends Database { Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp); DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); - update(threadId, false); + update(threadId, false, true); notifyConversationListeners(threadId); } + public List setRead(long threadId, long lastReadTime) { + + final List smsRecords = DatabaseComponent.get(context).smsDatabase().setMessagesRead(threadId, lastReadTime); + final List 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() {{ + addAll(smsRecords); + addAll(mmsRecords); + }}; + } + public List setRead(long threadId, boolean lastSeen) { ContentValues contentValues = new ContentValues(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) { ContentValues contentValues = new ContentValues(1); contentValues.put(TYPE, distributionType); @@ -352,6 +366,14 @@ public class ThreadDatabase extends Database { 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) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); 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 + " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + " 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 " + - RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " + + " WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " + + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL"; cursor = db.rawQuery(query, null); @@ -481,7 +503,7 @@ public class ThreadDatabase extends Database { } 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 "; return getConversationList(where); } @@ -517,21 +539,50 @@ public class ThreadDatabase extends Database { return db.rawQuery(query, null); } - public void setLastSeen(long threadId, long timestamp) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - ContentValues contentValues = new ContentValues(1); - if (timestamp == -1) { - contentValues.put(LAST_SEEN, SnodeAPI.getNowWithOffset()); - } else { - contentValues.put(LAST_SEEN, timestamp); - } + /** + * @param threadId + * @param timestamp + * @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, long 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)}); + 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(); + 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 getLastSeenAndHasSent(long threadId) { @@ -634,13 +685,19 @@ public class ThreadDatabase extends Database { try { cursor = db.query(TABLE_NAME, new String[]{ID}, where, recipientsArg, null, null, null); - + long threadId; + boolean created = false; if (cursor != null && cursor.moveToFirst()) { - return cursor.getLong(cursor.getColumnIndexOrThrow(ID)); + threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); } else { 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 { if (cursor != null) cursor.close(); @@ -679,13 +736,14 @@ public class ThreadDatabase extends Database { new String[] {String.valueOf(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(); long count = mmsSmsDatabase.getConversationCount(threadId); - boolean shouldDeleteEmptyThread = deleteThreadOnEmpty(threadId); + boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && deleteThreadOnEmpty(threadId); if (count == 0 && shouldDeleteEmptyThread) { deleteThread(threadId); @@ -708,12 +766,10 @@ public class ThreadDatabase extends Database { updateThread(threadId, count, getFormattedBodyFor(record), getAttachmentUriFor(record), record.getTimestamp(), record.getDeliveryStatus(), record.getDeliveryReceiptCount(), record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount()); - notifyConversationListListeners(); return false; } else { if (shouldDeleteEmptyThread) { deleteThread(threadId); - notifyConversationListListeners(); return true; } return false; @@ -721,6 +777,8 @@ public class ThreadDatabase extends Database { } finally { if (reader != null) reader.close(); + notifyConversationListListeners(); + notifyConversationListeners(threadId); } } @@ -732,10 +790,32 @@ public class ThreadDatabase extends Database { new String[] {String.valueOf(threadId)}); notifyConversationListeners(threadId); + notifyConversationListListeners(); } - public void markAllAsRead(long threadId, boolean isGroupRecipient) { - List messages = setRead(threadId, true); + public boolean isPinned(long threadId) { + 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 messages = setRead(threadId, lastSeenTime); if (isGroupRecipient) { for (MarkedMessageInfo message: messages) { MarkReadReceiver.scheduleDeletion(context, message.getExpirationInfo()); @@ -743,7 +823,8 @@ public class ThreadDatabase extends Database { } else { 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 8a4473b40..89bda0994 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -11,7 +11,6 @@ import androidx.core.app.NotificationCompat; import net.zetetic.database.sqlcipher.SQLiteConnection; import net.zetetic.database.sqlcipher.SQLiteDatabase; import net.zetetic.database.sqlcipher.SQLiteDatabaseHook; -import net.zetetic.database.sqlcipher.SQLiteException; import net.zetetic.database.sqlcipher.SQLiteOpenHelper; 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.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase; +import org.thoughtcrime.securesms.database.ConfigDatabase; import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.EmojiSearchDatabase; 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.ThreadDatabase; import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities; import java.io.File; @@ -85,9 +86,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV38 = 59; private static final int lokiV39 = 60; 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 - 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 String CIPHER3_DATABASE_NAME = "signal.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); } - 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() { @Override public void preKey(SQLiteConnection connection) { SQLCipherOpenHelper.applySQLCipherPragmas(connection, useSQLCipher4); } @@ -340,6 +343,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(ThreadDatabase.getUnreadMentionCountCommand()); db.execSQL(SmsDatabase.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, MmsDatabase.CREATE_INDEXS); @@ -351,6 +355,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { executeStatements(db, ReactionDatabase.CREATE_INDEXS); executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS); + db.execSQL(RecipientDatabase.getAddWrapperHash()); } @Override @@ -583,6 +588,16 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { 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(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt index 6f26c6ae3..936e4f287 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.dependencies import dagger.Binds import dagger.Module +import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.session.libsession.utilities.AppTextSecurePreferences @@ -19,4 +20,10 @@ abstract class AppModule { @Binds abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface AppComponent { + fun getPrefs(): TextSecurePreferences } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt new file mode 100644 index 000000000..d664ffedb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -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? +) : + 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 = mutableListOf() + fun registerListener(listener: ConfigFactoryUpdateListener) { + listeners += listener + } + + fun unregisterListener(listener: ConfigFactoryUpdateListener) { + listeners -= listener + } + + private inline fun 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 = + 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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt index 60d31a19d..f2c046e0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt @@ -45,4 +45,5 @@ interface DatabaseComponent { fun attachmentProvider(): MessageDataProvider fun blindedIdMappingDatabase(): BlindedIdMappingDatabase fun groupMemberDatabase(): GroupMemberDatabase + fun configDatabase(): ConfigDatabase } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt index 3372e1033..524100190 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt @@ -6,7 +6,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import net.zetetic.database.sqlcipher.SQLiteDatabase import org.session.libsession.database.MessageDataProvider import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider import org.thoughtcrime.securesms.crypto.AttachmentSecret @@ -132,10 +131,18 @@ object DatabaseModule { @Provides @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 @Singleton fun provideAttachmentProvider(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): MessageDataProvider = DatabaseAttachmentProvider(context, openHelper) + @Provides + @Singleton + fun provideConfigDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): ConfigDatabase = ConfigDatabase(context, openHelper) + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/InjectableType.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/InjectableType.java deleted file mode 100644 index 033b3ef45..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/InjectableType.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.thoughtcrime.securesms.dependencies; - -public interface InjectableType { -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt new file mode 100644 index 000000000..cd4b07133 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt @@ -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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt index 8b880d218..74e2cac4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt @@ -98,7 +98,7 @@ class NewMessageFragment : Fragment() { private fun hideLoader() { binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) binding.loader.visibility = View.GONE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt new file mode 100644 index 000000000..8b362d70d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt @@ -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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt index 62e762316..9fee8adaf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt @@ -16,6 +16,7 @@ import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import nl.komponents.kovenant.Promise 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.ThemeUtil import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity 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.groups.ClosedGroupManager.updateLegacyGroup import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeOut import java.io.IOException +import javax.inject.Inject +@AndroidEntryPoint class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { + + @Inject + lateinit var groupConfigFactory: ConfigFactory + @Inject + lateinit var storage: Storage + private val originalMembers = HashSet() private val zombies = HashSet() private val members = HashSet() @@ -289,7 +302,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { isLoading = true loaderContainer.fadeIn() val promise: Promise = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) { - MessageSender.explicitLeave(groupPublicKey!!, true) + MessageSender.explicitLeave(groupPublicKey!!, false) } else { task { if (hasNameChanged) { @@ -306,6 +319,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { promise.successUi { loaderContainer.fadeOut() isLoading = false + updateGroupConfig() finish() }.failUi { exception -> 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, val zombieMembers: List) { } + 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, val zombieMembers: List) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index a3d0e6d25..d4c5acf4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -6,6 +6,7 @@ import android.graphics.Bitmap; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.DistributionTypes; 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.util.BitmapUtil; +import java.io.IOException; import java.util.HashSet; import java.util.LinkedList; import java.util.Objects; import java.util.Set; +import network.loki.messenger.libsession_util.UserGroupsConfig; + public class GroupManager { public static long getOpenGroupThreadID(String id, @NonNull Context context) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt index d37b17ef9..ae59c3833 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt @@ -55,7 +55,7 @@ class JoinCommunityFragment : Fragment() { fun hideLoader() { binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) binding.loader.visibility = View.GONE } @@ -79,7 +79,7 @@ class JoinCommunityFragment : Fragment() { val openGroupID = "$sanitizedServer.${openGroup.room}" OpenGroupManager.add(sanitizedServer, openGroup.room, openGroup.serverPublicKey, requireContext()) val storage = MessagingModuleConfiguration.shared.storage - storage.onOpenGroupAdded(sanitizedServer) + storage.onOpenGroupAdded(sanitizedServer, openGroup.room) val threadID = GroupManager.getOpenGroupThreadID(openGroupID, requireContext()) val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index dbdf2615a..2754c70f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -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.sending_receiving.pollers.OpenGroupPoller import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import java.util.concurrent.Executors object OpenGroupManager { @@ -40,7 +40,13 @@ object OpenGroupManager { if (isPolling) { return } isPolling = true 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) { servers.forEach { server -> pollers[server]?.stop() // Shouldn't be necessary @@ -58,14 +64,14 @@ object OpenGroupManager { } @WorkerThread - fun add(server: String, room: String, publicKey: String, context: Context): OpenGroupApi.RoomInfo? { + fun add(server: String, room: String, publicKey: String, context: Context): Pair { val openGroupID = "$server.$room" val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) val storage = MessagingModuleConfiguration.shared.storage val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() // Check it it's added already val existingOpenGroup = threadDB.getOpenGroupChat(threadID) - if (existingOpenGroup != null) { return null } + if (existingOpenGroup != null) { return threadID to null } // Clear any existing data if needed storage.removeLastDeletionServerID(room, server) storage.removeLastMessageServerID(room, server) @@ -86,7 +92,7 @@ object OpenGroupManager { pollInfo = info.toPollInfo(), createGroupIfMissingWithPublicKey = publicKey ) - return info + return threadID to info } fun restartPollerForServer(server: String) { @@ -102,23 +108,27 @@ object OpenGroupManager { } } + @WorkerThread fun delete(server: String, room: String, context: Context) { val storage = MessagingModuleConfiguration.shared.storage + val configFactory = MessagingModuleConfiguration.shared.configFactory val threadDB = DatabaseComponent.get(context).threadDatabase() - val openGroupID = "$server.$room" + val openGroupID = "${server.removeSuffix("/")}.$room" val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) val recipient = threadDB.getRecipientForThreadId(threadID) ?: return threadDB.setThreadArchived(threadID) val groupID = recipient.address.serialize() // Stop the poller if needed val openGroups = storage.getAllOpenGroups().filter { it.value.server == server } - if (openGroups.count() == 1) { + if (openGroups.isNotEmpty()) { synchronized(pollUpdaterLock) { val poller = pollers[server] poller?.stop() pollers.remove(server) } } + configFactory.userGroups?.eraseCommunity(server, room) + configFactory.convoVolatile?.eraseCommunity(server, room) // Delete storage.removeLastDeletionServerID(room, server) storage.removeLastMessageServerID(room, server) @@ -126,19 +136,19 @@ object OpenGroupManager { storage.removeLastOutboxMessageId(server) val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase() lokiThreadDB.removeOpenGroupChat(threadID) - ThreadUtils.queue { - threadDB.deleteConversation(threadID) // Must be invoked on a background thread - GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread - } + storage.deleteConversation(threadID) // 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? { val url = HttpUrl.parse(urlAsString) ?: return null val server = OpenGroup.getServer(urlAsString) val room = url.pathSegments().firstOrNull() ?: 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index 7e9d2640a..702bf3392 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -7,10 +7,15 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory 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 { private lateinit var binding: FragmentConversationBottomSheetBinding //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. lateinit var thread: ThreadRecord + @Inject lateinit var configFactory: ConfigFactory + var onViewDetailsTapped: (() -> Unit?)? = null var onCopyConversationId: (() -> 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.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.pinTextView.isVisible = !thread.isPinned binding.unpinTextView.isVisible = thread.isPinned diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt index c6a6e1f7f..31b281c6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -6,12 +6,12 @@ import android.graphics.Typeface import android.graphics.drawable.ColorDrawable import android.util.AttributeSet import android.util.TypedValue -import android.view.LayoutInflater import android.view.View import android.widget.LinearLayout import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewConversationBinding 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_NONE import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.getAccentColor +import org.thoughtcrime.securesms.util.getConversationUnread import java.util.Locale +import javax.inject.Inject +@AndroidEntryPoint class ConversationView : LinearLayout { + + @Inject lateinit var configFactory: ConfigFactory + private val binding: ViewConversationBinding by lazy { ViewConversationBinding.bind(this) } private val screenWidth = Resources.getSystem().displayMetrics.widthPixels var thread: ThreadRecord? = null @@ -58,7 +65,6 @@ class ConversationView : LinearLayout { } else { ContextCompat.getDrawable(context, R.drawable.conversation_view_background) } - binding.profilePictureView.root.glide = glide val unreadCount = thread.unreadCount if (thread.recipient.isBlocked) { 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 binding.accentView.visibility = if (unreadCount > 0 && !thread.isRead) View.VISIBLE else View.INVISIBLE } - val formattedUnreadCount = if (thread.isRead) { + val formattedUnreadCount = if (unreadCount == 0) { null } else { if (unreadCount < 10000) unreadCount.toString() else "9999+" @@ -80,6 +86,7 @@ class ConversationView : LinearLayout { val textSize = if (unreadCount < 1000) 12.0f else 10.0f binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead) + || (configFactory.convoVolatile?.getConversationUnread(thread) == true) binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroup) val senderDisplayName = getUserDisplayName(thread.recipient) @@ -117,18 +124,18 @@ class ConversationView : LinearLayout { thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check) else -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) } - binding.profilePictureView.root.update(thread.recipient) + binding.profilePictureView.update(thread.recipient) } fun recycle() { - binding.profilePictureView.root.recycle() + binding.profilePictureView.recycle() } private fun getUserDisplayName(recipient: Recipient): String? { return if (recipient.isLocalNumber) { context.getString(R.string.note_to_self) } else { - recipient.name // Internally uses the Contact API + recipient.toShortString() // Internally uses the Contact API } } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 0215040d3..72bd098f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -1,19 +1,22 @@ package org.thoughtcrime.securesms.home +import android.Manifest +import android.app.NotificationManager import android.content.BroadcastReceiver +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.ClipData -import android.content.ClipboardManager import android.os.Bundle import android.text.SpannableString import android.widget.Toast import androidx.activity.viewModels -import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -27,11 +30,14 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityHomeBinding import network.loki.messenger.databinding.ViewMessageRequestBannerBinding +import network.loki.messenger.libsession_util.ConfigBase import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.JobQueue 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.GroupUtil 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.toHexString import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.MuteDialog import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.start.NewConversationFragment 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.MmsSmsDatabase import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.OpenGroupManager 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.onboarding.SeedActivity import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate +import org.thoughtcrime.securesms.permissions.Permissions 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.DateUtils import org.thoughtcrime.securesms.util.IP2Country @@ -80,6 +90,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), SeedReminderViewDelegate, GlobalSearchInputLayout.GlobalSearchInputLayoutListener { + companion object { + const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING" + } + + private lateinit var binding: ActivityHomeBinding private lateinit var glide: GlideRequests private var broadcastReceiver: BroadcastReceiver? = null @@ -87,8 +102,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), @Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @Inject lateinit var recipientDatabase: RecipientDatabase + @Inject lateinit var storage: Storage @Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var textSecurePreferences: TextSecurePreferences + @Inject lateinit var configFactory: ConfigFactory private val globalSearchViewModel by viewModels() private val homeViewModel by viewModels() @@ -97,7 +114,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), get() = textSecurePreferences.getLocalNumber()!! private val homeAdapter: HomeAdapter by lazy { - HomeAdapter(context = this, listener = this) + HomeAdapter(context = this, configFactory = configFactory, listener = this) } private val globalSearchAdapter = GlobalSearchAdapter { model -> @@ -151,22 +168,23 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // Set up Glide glide = GlideApp.with(this) // Set up toolbar buttons - binding.profileButton.root.glide = glide - binding.profileButton.root.setOnClickListener { openSettings() } + binding.profileButton.setOnClickListener { openSettings() } binding.searchViewContainer.setOnClickListener { binding.globalSearchInputLayout.requestFocus() } binding.sessionToolbar.disableClipping() // Set up seed reminder view - val hasViewedSeed = textSecurePreferences.getHasViewedSeed() - if (!hasViewedSeed) { - binding.seedReminderView.isVisible = true - binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated - binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1) - binding.seedReminderView.setProgress(80, false) - binding.seedReminderView.delegate = this@HomeActivity - } else { - binding.seedReminderView.isVisible = false + lifecycleScope.launchWhenStarted { + val hasViewedSeed = textSecurePreferences.getHasViewedSeed() + if (!hasViewedSeed) { + binding.seedReminderView.isVisible = true + binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated + binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1) + binding.seedReminderView.setProgress(80, false) + binding.seedReminderView.delegate = this@HomeActivity + } else { + binding.seedReminderView.isVisible = false + } } setupMessageRequestsBanner() // Set up recycler view @@ -176,6 +194,11 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.recyclerView.adapter = homeAdapter binding.globalSearchRecycler.adapter = globalSearchAdapter + binding.configOutdatedView.setOnClickListener { + textSecurePreferences.setHasLegacyConfig(false) + updateLegacyConfigView() + } + // Set up empty state view binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() } IP2Country.configureIfNeeded(this@HomeActivity) @@ -192,6 +215,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), this.broadcastReceiver = broadcastReceiver 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 { launch(Dispatchers.IO) { // Double check that the long poller is up @@ -212,6 +244,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } } + // monitor the global search VM query launch { binding.globalSearchInputLayout.query @@ -264,6 +297,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } 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) { @@ -312,16 +353,26 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } + private fun updateLegacyConfigView() { + binding.configOutdatedView.isVisible = ConfigBase.isNewConfigEnabled(textSecurePreferences.hasForcedNewConfig(), SnodeAPI.nowWithOffset) + && textSecurePreferences.getHasLegacyConfig() + } + override fun onResume() { super.onResume() ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true) if (textSecurePreferences.getLocalNumber() == null) { return; } // This can be the case after a secondary device is auto-cleared IdentityKeyUtil.checkUpdate(this) - binding.profileButton.root.recycle() // clear cached image before update tje profilePictureView - binding.profileButton.root.update() + binding.profileButton.recycle() // clear cached image before update tje profilePictureView + binding.profileButton.update() if (textSecurePreferences.getHasViewedSeed()) { 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()) { lifecycleScope.launch(Dispatchers.IO) { ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity) @@ -388,10 +439,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } private fun updateProfileButton() { - binding.profileButton.root.publicKey = publicKey - binding.profileButton.root.displayName = textSecurePreferences.getProfileName() - binding.profileButton.root.recycle() - binding.profileButton.root.update() + binding.profileButton.publicKey = publicKey + binding.profileButton.displayName = textSecurePreferences.getProfileName() + binding.profileButton.recycle() + binding.profileButton.update() } // endregion @@ -488,39 +539,37 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } private fun blockConversation(thread: ThreadRecord) { - AlertDialog.Builder(this) - .setTitle(R.string.RecipientPreferenceActivity_block_this_contact_question) - .setMessage(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.RecipientPreferenceActivity_block) { dialog, _ -> - lifecycleScope.launch(Dispatchers.IO) { - recipientDatabase.setBlocked(thread.recipient, true) - // TODO: Remove in UserConfig branch - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity) - withContext(Dispatchers.Main) { - binding.recyclerView.adapter!!.notifyDataSetChanged() - dialog.dismiss() - } + showSessionDialog { + title(R.string.RecipientPreferenceActivity_block_this_contact_question) + text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) + button(R.string.RecipientPreferenceActivity_block) { + lifecycleScope.launch(Dispatchers.IO) { + storage.setBlocked(listOf(thread.recipient), true) + + withContext(Dispatchers.Main) { + binding.recyclerView.adapter!!.notifyDataSetChanged() } - }.show() + } + } + cancelButton() + } } private fun unblockConversation(thread: ThreadRecord) { - AlertDialog.Builder(this) - .setTitle(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) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.RecipientPreferenceActivity_unblock) { dialog, _ -> - lifecycleScope.launch(Dispatchers.IO) { - recipientDatabase.setBlocked(thread.recipient, false) - // TODO: Remove in UserConfig branch - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@HomeActivity) - withContext(Dispatchers.Main) { - binding.recyclerView.adapter!!.notifyDataSetChanged() - dialog.dismiss() - } + showSessionDialog { + title(R.string.RecipientPreferenceActivity_unblock_this_contact_question) + text(R.string.RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact) + button(R.string.RecipientPreferenceActivity_unblock) { + lifecycleScope.launch(Dispatchers.IO) { + storage.setBlocked(listOf(thread.recipient), false) + + withContext(Dispatchers.Main) { + binding.recyclerView.adapter!!.notifyDataSetChanged() } - }.show() + } + } + cancelButton() + } } private fun setConversationMuted(thread: ThreadRecord, isMuted: Boolean) { @@ -532,7 +581,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } } else { - MuteDialog.show(this) { until: Long -> + showMuteDialog(this) { until -> lifecycleScope.launch(Dispatchers.IO) { recipientDatabase.setMuted(thread.recipient, until) withContext(Dispatchers.Main) { @@ -554,14 +603,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun setConversationPinned(threadId: Long, pinned: Boolean) { lifecycleScope.launch(Dispatchers.IO) { - threadDb.setPinned(threadId, pinned) + storage.setPinned(threadId, pinned) homeViewModel.tryUpdateChannel() } } private fun markAllAsRead(thread: ThreadRecord) { 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 { resources.getString(R.string.activity_home_delete_conversation_dialog_message) } - val dialog = AlertDialog.Builder(this) - dialog.setMessage(message) - dialog.setPositiveButton(R.string.yes) { _, _ -> - lifecycleScope.launch(Dispatchers.Main) { - val context = this@HomeActivity as Context - // Cancel any outstanding jobs - DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID) - // Send a leave group message if this is an active closed group - if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) { - var isClosedGroup: Boolean - var groupPublicKey: String? - try { - groupPublicKey = GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString() - isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) - } catch (e: IOException) { - groupPublicKey = null - isClosedGroup = false + + showSessionDialog { + text(message) + button(R.string.yes) { + lifecycleScope.launch(Dispatchers.Main) { + val context = this@HomeActivity + // Cancel any outstanding jobs + DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID) + // Send a leave group message if this is an active closed group + if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) { + try { + GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString() + .takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup) + ?.let { MessageSender.explicitLeave(it, false) } + } catch (_: IOException) { + } } - if (isClosedGroup) { - MessageSender.explicitLeave(groupPublicKey!!, false) + // Delete the conversation + 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() { @@ -633,17 +675,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } private fun hideMessageRequests() { - AlertDialog.Builder(this) - .setMessage("Hide message requests?") - .setPositiveButton(R.string.yes) { _, _ -> + showSessionDialog { + text("Hide message requests?") + button(R.string.yes) { textSecurePreferences.setHasHiddenMessageRequests() setupMessageRequestsBanner() homeViewModel.tryUpdateChannel() } - .setNegativeButton(R.string.no) { _, _ -> - // Do nothing - } - .create().show() + button(R.string.no) + } } private fun showNewConversation() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt index 4273794f5..eaf242aae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -10,10 +10,12 @@ import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID import network.loki.messenger.R import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.mms.GlideRequests class HomeAdapter( private val context: Context, + private val configFactory: ConfigFactory, private val listener: ConversationClickListener ) : RecyclerView.Adapter(), ListUpdateCallback { @@ -29,7 +31,7 @@ class HomeAdapter( get() = _data.toList() set(newData) { val previousData = _data.toList() - val diff = HomeDiffUtil(previousData, newData, context) + val diff = HomeDiffUtil(previousData, newData, context, configFactory) val diffResult = DiffUtil.calculateDiff(diff) _data = newData diffResult.dispatchUpdatesTo(this as ListUpdateCallback) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt index b883709c0..0fe93d41d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt @@ -3,11 +3,14 @@ package org.thoughtcrime.securesms.home import android.content.Context import androidx.recyclerview.widget.DiffUtil import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.util.getConversationUnread class HomeDiffUtil( private val old: List, private val new: List, - private val context: Context + private val context: Context, + private val configFactory: ConfigFactory ): DiffUtil.Callback() { override fun getOldListSize(): Int = old.size @@ -42,7 +45,9 @@ class HomeDiffUtil( oldItem.isFailed == newItem.isFailed && oldItem.isDelivered == newItem.isDelivered && oldItem.isSent == newItem.isSent && - oldItem.isPending == newItem.isPending + oldItem.isPending == newItem.isPending && + oldItem.lastSeen == newItem.lastSeen && + configFactory.convoVolatile?.getConversationUnread(newItem) != true ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt index 947bd89b4..7ab7bfb50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt @@ -9,9 +9,14 @@ import android.graphics.Paint import android.util.AttributeSet import android.view.View import androidx.annotation.ColorInt +import androidx.lifecycle.coroutineScope import androidx.localbroadcastmanager.content.LocalBroadcastManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.snode.OnionRequestAPI +import org.thoughtcrime.securesms.conversation.v2.ViewUtil import org.thoughtcrime.securesms.util.getColorWithID import org.thoughtcrime.securesms.util.toPx @@ -29,6 +34,8 @@ class PathStatusView : View { result } + private var updateJob: Job? = null + constructor(context: Context) : super(context) { initialize() } @@ -87,16 +94,21 @@ class PathStatusView : View { private fun handlePathsBuiltEvent() { update() } private fun update() { - if (OnionRequestAPI.paths.isNotEmpty()) { - setBackgroundResource(R.drawable.accent_dot) - val hasPathsColor = context.getColor(R.color.accent_green) - mainColor = hasPathsColor - sessionShadowColor = hasPathsColor - } else { - setBackgroundResource(R.drawable.paths_building_dot) - val pathsBuildingColor = resources.getColorWithID(R.color.paths_building, context.theme) - mainColor = pathsBuildingColor - sessionShadowColor = pathsBuildingColor + if (updateJob?.isActive != true) { // false or null + updateJob = ViewUtil.getActivityLifecycle(this)?.coroutineScope?.launchWhenStarted { + val paths = withContext(Dispatchers.IO) { OnionRequestAPI.paths } + if (paths.isNotEmpty()) { + setBackgroundResource(R.drawable.accent_dot) + val hasPathsColor = context.getColor(R.color.accent_green) + mainColor = hasPathsColor + sessionShadowColor = hasPathsColor + } else { + setBackgroundResource(R.drawable.paths_building_dot) + val pathsBuildingColor = resources.getColorWithID(R.color.paths_building, context.theme) + mainColor = pathsBuildingColor + sessionShadowColor = pathsBuildingColor + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt index bc9a9bece..ad8f2d042 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt @@ -25,9 +25,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.ThreadDatabase -import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideApp -import org.thoughtcrime.securesms.util.UiModeUtilities import javax.inject.Inject @AndroidEntryPoint @@ -55,12 +53,12 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { val recipient = Recipient.from(requireContext(), Address.fromSerialized(publicKey), false) val threadRecipient = threadDb.getRecipientForThreadId(threadID) ?: return dismiss() with(binding) { - profilePictureView.root.publicKey = publicKey - profilePictureView.root.glide = GlideApp.with(this@UserDetailsBottomSheet) - profilePictureView.root.isLarge = true - profilePictureView.root.update(recipient) + profilePictureView.publicKey = publicKey + profilePictureView.isLarge = true + profilePictureView.update(recipient) nameTextViewContainer.visibility = View.VISIBLE nameTextViewContainer.setOnClickListener { + if (recipient.isOpenGroupInboxRecipient || recipient.isOpenGroupOutboxRecipient) return@setOnClickListener nameTextViewContainer.visibility = View.INVISIBLE nameEditTextContainer.visibility = View.VISIBLE nicknameEditText.text = null @@ -87,8 +85,14 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { } nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally - publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient && !threadRecipient.isOpenGroupInboxRecipient - messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey) == IdPrefix.BLINDED + nameEditIcon.isVisible = threadRecipient.isContactRecipient + && !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.setOnLongClickListener { val clipboard = @@ -130,10 +134,10 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { newNickName = nicknameEditText.text.toString() } val publicKey = recipient.address.serialize() - val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase() - val contact = contactDB.getContactWithSessionID(publicKey) ?: Contact(publicKey) + val storage = MessagingModuleConfiguration.shared.storage + val contact = storage.getContactWithSessionID(publicKey) ?: Contact(publicKey) contact.nickname = newNickName - contactDB.setContact(contact) + storage.setContact(contact) nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt index fab8bca99..7cf953be2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt @@ -83,22 +83,20 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi override fun onViewRecycled(holder: RecyclerView.ViewHolder) { 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) { - val binding = ViewGlobalSearchResultBinding.bind(view).apply { - searchResultProfilePicture.root.glide = GlideApp.with(root) - } + val binding = ViewGlobalSearchResultBinding.bind(view) fun bindPayload(newQuery: String, model: Model) { bindQuery(newQuery, model) } fun bind(query: String, model: Model) { - binding.searchResultProfilePicture.root.recycle() + binding.searchResultProfilePicture.recycle() when (model) { is Model.GroupConversation -> bindModel(query, model) is Model.Contact -> bindModel(query, model) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt index 2c64ded86..5371bb71c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -12,6 +12,7 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient 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.Header import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages import org.thoughtcrime.securesms.util.DateUtils @@ -76,6 +77,8 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) { } 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) { - binding.searchResultProfilePicture.root.isVisible = true + binding.searchResultProfilePicture.isVisible = true binding.searchResultSavedMessages.isVisible = false binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup binding.searchResultTimestamp.isVisible = 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 binding.searchResultTitle.text = getHighlight(query, nameString) @@ -105,14 +108,14 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) { } fun ContentView.bindModel(query: String?, model: ContactModel) { - binding.searchResultProfilePicture.root.isVisible = true + binding.searchResultProfilePicture.isVisible = true binding.searchResultSavedMessages.isVisible = false binding.searchResultSubtitle.isVisible = false binding.searchResultTimestamp.isVisible = false binding.searchResultSubtitle.text = null val recipient = 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() binding.searchResultTitle.text = getHighlight(query, nameString) } @@ -121,12 +124,12 @@ fun ContentView.bindModel(model: SavedMessages) { binding.searchResultSubtitle.isVisible = false binding.searchResultTimestamp.isVisible = false binding.searchResultTitle.setText(R.string.note_to_self) - binding.searchResultProfilePicture.root.isVisible = false + binding.searchResultProfilePicture.isVisible = false binding.searchResultSavedMessages.isVisible = true } fun ContentView.bindModel(query: String?, model: Message) { - binding.searchResultProfilePicture.root.isVisible = true + binding.searchResultProfilePicture.isVisible = true binding.searchResultSavedMessages.isVisible = false binding.searchResultTimestamp.isVisible = true // val hasUnreads = model.unread > 0 @@ -135,7 +138,7 @@ fun ContentView.bindModel(query: String?, model: Message) { // binding.unreadCountTextView.text = model.unread.toString() // } 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() if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) { // group chat, bind diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt index 07da14b09..a85ea525a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt @@ -154,7 +154,7 @@ class KeyboardPageSearchView @JvmOverloads constructor( .setDuration(REVEAL_DURATION) .alpha(0f) .setListener(object : AnimationCompleteListener() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { visibility = INVISIBLE } }) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index 9a8d06129..af3d269c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -34,7 +34,6 @@ class MessageRequestView : LinearLayout { // region Updating fun bind(thread: ThreadRecord, glide: GlideRequests) { this.thread = thread - binding.profilePictureView.root.glide = glide val senderDisplayName = getUserDisplayName(thread.recipient) ?: thread.recipient.address.toString() binding.displayNameTextView.text = senderDisplayName @@ -44,12 +43,12 @@ class MessageRequestView : LinearLayout { binding.snippetTextView.text = snippet post { - binding.profilePictureView.root.update(thread.recipient) + binding.profilePictureView.update(thread.recipient) } } fun recycle() { - binding.profilePictureView.root.recycle() + binding.profilePictureView.recycle() } private fun getUserDisplayName(recipient: Recipient): String? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt index 50ed4628e..caecbcd87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.messagerequests -import android.app.AlertDialog import android.content.Intent import android.database.Cursor 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.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.push import javax.inject.Inject @@ -49,7 +49,7 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat adapter.glide = glide binding.recyclerView.adapter = adapter - binding.clearAllMessageRequestsButton.setOnClickListener { deleteAllAndBlock() } + binding.clearAllMessageRequestsButton.setOnClickListener { deleteAll() } } override fun onResume() { @@ -77,34 +77,34 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat } override fun onBlockConversationClick(thread: ThreadRecord) { - val dialog = AlertDialog.Builder(this) - dialog.setTitle(R.string.RecipientPreferenceActivity_block_this_contact_question) - .setMessage(R.string.message_requests_block_message) - .setPositiveButton(R.string.recipient_preferences__block) { _, _ -> - viewModel.blockMessageRequest(thread) - LoaderManager.getInstance(this).restartLoader(0, null, this) - } - .setNegativeButton(R.string.no) { _, _ -> - // Do nothing - } - dialog.create().show() + fun doBlock() { + viewModel.blockMessageRequest(thread) + LoaderManager.getInstance(this).restartLoader(0, null, this) + } + + showSessionDialog { + title(R.string.RecipientPreferenceActivity_block_this_contact_question) + text(R.string.message_requests_block_message) + button(R.string.recipient_preferences__block) { doBlock() } + button(R.string.no) + } } override fun onDeleteConversationClick(thread: ThreadRecord) { - val dialog = AlertDialog.Builder(this) - dialog.setTitle(R.string.decline) - .setMessage(resources.getString(R.string.message_requests_decline_message)) - .setPositiveButton(R.string.decline) { _,_ -> - viewModel.deleteMessageRequest(thread) - LoaderManager.getInstance(this).restartLoader(0, null, this) - lifecycleScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity) - } + fun doDecline() { + viewModel.deleteMessageRequest(thread) + LoaderManager.getInstance(this).restartLoader(0, null, this) + lifecycleScope.launch(Dispatchers.IO) { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity) } - .setNegativeButton(R.string.no) { _, _ -> - // Do nothing - } - dialog.create().show() + } + + showSessionDialog { + 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() { @@ -113,19 +113,19 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat binding.clearAllMessageRequestsButton.isVisible = threadCount != 0 } - private fun deleteAllAndBlock() { - val dialog = AlertDialog.Builder(this) - dialog.setMessage(resources.getString(R.string.message_requests_clear_all_message)) - dialog.setPositiveButton(R.string.yes) { _, _ -> - viewModel.clearAllMessageRequests() + private fun deleteAll() { + fun doDeleteAllAndBlock() { + viewModel.clearAllMessageRequests(false) LoaderManager.getInstance(this).restartLoader(0, null, this) lifecycleScope.launch(Dispatchers.IO) { 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() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt index 89a841dc0..10142cc8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt @@ -48,6 +48,7 @@ class MessageRequestsAdapter( private fun showPopupMenu(view: MessageRequestView) { val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view) 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 -> if (menuItem.itemId == R.id.menu_delete_message_request) { listener.onDeleteConversationClick(view.thread!!) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt index 2f448932d..a3a7caf8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt @@ -25,8 +25,8 @@ class MessageRequestsViewModel @Inject constructor( repository.deleteMessageRequest(thread) } - fun clearAllMessageRequests() = viewModelScope.launch { - repository.clearAllMessageRequests() + fun clearAllMessageRequests(block: Boolean) = viewModelScope.launch { + repository.clearAllMessageRequests(block) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index 327492e95..0157d8ad4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -60,7 +60,6 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilit import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities; import org.thoughtcrime.securesms.crypto.KeyPairUtilities; import org.thoughtcrime.securesms.database.LokiThreadDatabase; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; @@ -160,8 +159,9 @@ public class DefaultMessageNotifier implements MessageNotifier { executor.cancel(); } - private void cancelActiveNotifications(@NonNull Context context) { + private boolean cancelActiveNotifications(@NonNull Context context) { NotificationManager notifications = ServiceUtil.getNotificationManager(context); + boolean hasNotifications = notifications.getActiveNotifications().length > 0; notifications.cancel(SUMMARY_NOTIFICATION_ID); try { @@ -175,6 +175,7 @@ public class DefaultMessageNotifier implements MessageNotifier { Log.w(TAG, e); notifications.cancelAll(); } + return hasNotifications; } private void cancelOrphanedNotifications(@NonNull Context context, NotificationState notificationState) { @@ -240,10 +241,6 @@ public class DefaultMessageNotifier implements MessageNotifier { !(recipient.isApproved() || threads.getLastSeenAndHasSent(threadId).second())) { TextSecurePreferences.removeHasHiddenMessageRequests(context); } - if (isVisible && recipient != null) { - List messageIds = threads.setRead(threadId, false); - if (SessionMetaProtocol.shouldSendReadReceipt(recipient)) { MarkReadReceiver.process(context, messageIds); } - } if (!TextSecurePreferences.isNotificationsEnabled(context) || (recipient != null && recipient.isMuted())) @@ -251,11 +248,21 @@ public class DefaultMessageNotifier implements MessageNotifier { return; } - if (!isVisible && !homeScreenVisible) { + if ((!isVisible && !homeScreenVisible) || hasExistingNotifications(context)) { 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 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)) { - cancelActiveNotifications(context); updateBadge(context, 0); + cancelActiveNotifications(context); clearReminder(context); return; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java index 6075be65e..309f2732f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java @@ -12,6 +12,8 @@ import androidx.core.app.NotificationManagerCompat; import com.annimon.stream.Collectors; 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.sending_receiving.MessageSender; 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.util.SessionMetaProtocol; -import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -52,18 +53,12 @@ public class MarkReadReceiver extends BroadcastReceiver { new AsyncTask() { @Override protected Void doInBackground(Void... params) { - List messageIdsCollection = new LinkedList<>(); - + long currentTime = SnodeAPI.getNowWithOffset(); for (long threadId : threadIds) { Log.i(TAG, "Marking as read: " + threadId); - List messageIds = DatabaseComponent.get(context).threadDatabase().setRead(threadId, true); - messageIdsCollection.addAll(messageIds); + StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage(); + storage.markConversationAsRead(threadId,currentTime, true); } - - process(context, messageIdsCollection); - - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context); - return null; } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt index 31117ae94..edd1bc274 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt @@ -15,6 +15,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentPagerAdapter import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter @@ -34,12 +35,19 @@ import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.MnemonicUtilities +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo +import javax.inject.Inject +@AndroidEntryPoint class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { + + @Inject + lateinit var configFactory: ConfigFactory + private lateinit var binding: ActivityLinkDeviceBinding internal val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage @@ -112,6 +120,7 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel val keyPairGenerationResult = KeyPairUtilities.generate(seed) val x25519KeyPair = keyPairGenerationResult.x25519KeyPair KeyPairUtilities.store(this@LinkDeviceActivity, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair) + configFactory.keyPairChanged() val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey val registrationID = KeyHelper.generateRegistrationId(false) TextSecurePreferences.setLocalRegistrationId(this@LinkDeviceActivity, registrationID) @@ -124,9 +133,8 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel .setAction(R.string.registration_activity__skip) { register(true) } val skipJob = launch { - delay(30_000L) + delay(15_000L) snackBar.show() - // show a dialog or something saying do you want to skip this bit? } // start polling and wait for updated message ApplicationContext.getInstance(this@LinkDeviceActivity).apply { diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt index 9cf9c3d04..2de626953 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.onboarding import android.animation.ArgbEvaluator import android.animation.ValueAnimator -import android.app.AlertDialog import android.content.Intent import android.graphics.drawable.TransitionDrawable import android.net.Uri @@ -20,6 +19,7 @@ import org.session.libsession.utilities.ThemeUtil import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.home.HomeActivity +import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.PNModeView import org.thoughtcrime.securesms.util.disableClipping @@ -151,18 +151,20 @@ class PNModeActivity : BaseActionBarActivity() { private fun register() { if (selectedOptionView == null) { - val dialog = AlertDialog.Builder(this) - dialog.setTitle(R.string.activity_pn_mode_no_option_picked_dialog_title) - dialog.setPositiveButton(R.string.ok) { _, _ -> } - dialog.create().show() + showSessionDialog { + title(R.string.activity_pn_mode_no_option_picked_dialog_title) + button(R.string.ok) + } return } + TextSecurePreferences.setIsUsingFCM(this, (selectedOptionView == binding.fcmOptionView)) val application = ApplicationContext.getInstance(this) application.startPollingIfNeeded() application.registerForFCMIfNeeded(true) val intent = Intent(this, HomeActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + intent.putExtra(HomeActivity.FROM_ONBOARDING, true) show(intent) } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt index 5531fea49..051cd7542 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt @@ -11,6 +11,7 @@ import android.text.style.ClickableSpan import android.text.style.StyleSpan import android.view.View import android.widget.Toast +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ActivityRecoveryPhraseRestoreBinding 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.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.MnemonicUtilities +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo +import javax.inject.Inject +@AndroidEntryPoint class RecoveryPhraseRestoreActivity : BaseActionBarActivity() { + + @Inject + lateinit var configFactory: ConfigFactory + private lateinit var binding: ActivityRecoveryPhraseRestoreBinding internal val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage @@ -81,6 +89,7 @@ class RecoveryPhraseRestoreActivity : BaseActionBarActivity() { val keyPairGenerationResult = KeyPairUtilities.generate(seed) val x25519KeyPair = keyPairGenerationResult.x25519KeyPair KeyPairUtilities.store(this, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair) + configFactory.keyPairChanged() val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey val registrationID = KeyHelper.generateRegistrationId(false) TextSecurePreferences.setLocalRegistrationId(this, registrationID) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt index b6fdaf9cf..6e082e000 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt @@ -16,6 +16,7 @@ import android.text.style.StyleSpan import android.view.View import android.widget.Toast import com.goterl.lazysodium.utils.KeyPair +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ActivityRegisterBinding import org.session.libsession.snode.SnodeModule @@ -26,10 +27,17 @@ import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo +import javax.inject.Inject +@AndroidEntryPoint class RegisterActivity : BaseActionBarActivity() { + + @Inject + lateinit var configFactory: ConfigFactory + private lateinit var binding: ActivityRegisterBinding internal val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage @@ -119,6 +127,7 @@ class RegisterActivity : BaseActionBarActivity() { database.clearReceivedMessageHashValues() KeyPairUtilities.store(this, seed!!, ed25519KeyPair!!, x25519KeyPair!!) + configFactory.keyPairChanged() val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey val registrationID = KeyHelper.generateRegistrationId(false) TextSecurePreferences.setLocalRegistrationId(this, registrationID) diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java index 2d7e6dae5..88ee67cb4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java @@ -162,15 +162,13 @@ public class Permissions { request.onResult(requestedPermissions, grantResults, new boolean[requestedPermissions.length]); } - @SuppressWarnings("ConstantConditions") private void executePermissionsRequestWithRationale(PermissionsRequest request) { - AlertDialog dialog = RationaleDialog.createFor(permissionObject.getContext(), rationaleDialogMessage, rationalDialogHeader) - .setPositiveButton(R.string.Permissions_continue, (d, which) -> executePermissionsRequest(request)) - .setNegativeButton(R.string.Permissions_not_now, (d, which) -> executeNoPermissionsRequest(request)) - .show(); - dialog.getWindow().setLayout((int)(permissionObject.getWindowWidth() * .75), ViewGroup.LayoutParams.WRAP_CONTENT); - Button positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE); - positiveButton.setContentDescription("Continue"); + RationaleDialog.show( + permissionObject.getContext(), + rationaleDialogMessage, + () -> executePermissionsRequest(request), + () -> executeNoPermissionsRequest(request), + rationalDialogHeader); } private void executePermissionsRequest(PermissionsRequest request) { @@ -257,7 +255,7 @@ public class Permissions { resultListener.onResult(permissions, grantResults, shouldShowRationaleDialog); } - private static Intent getApplicationSettingsIntent(@NonNull Context context) { + static Intent getApplicationSettingsIntent(@NonNull Context context) { Intent intent = new Intent(); intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); Uri uri = Uri.fromParts("package", context.getPackageName(), null); @@ -354,20 +352,8 @@ public class Permissions { @Override public void run() { Context context = this.context.get(); - - if (context != null) { - 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(); - } + if (context == null) return; + SettingsDialog.show(context, message); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.java b/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.java deleted file mode 100644 index a346d591a..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.java +++ /dev/null @@ -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(R.id.header_container) + view.findViewById(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() } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/SettingsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/permissions/SettingsDialog.kt new file mode 100644 index 000000000..a4efd8d87 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/SettingsDialog.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt index a66dd7428..16499cc4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.preferences -import android.app.AlertDialog import android.os.Bundle import androidx.activity.viewModels import androidx.core.view.isVisible @@ -8,6 +7,7 @@ import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ActivityBlockedContactsBinding import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.showSessionDialog @AndroidEntryPoint class BlockedContactsActivity: PassphraseRequiredActionBarActivity() { @@ -19,17 +19,12 @@ class BlockedContactsActivity: PassphraseRequiredActionBarActivity() { val adapter: BlockedContactsAdapter by lazy { BlockedContactsAdapter(viewModel) } fun unblock() { - // show dialog - val title = viewModel.getTitle(this) - - val message = viewModel.getMessage(this) - - AlertDialog.Builder(this) - .setTitle(title) - .setMessage(message) - .setPositiveButton(R.string.continue_2) { _, _ -> viewModel.unblock(this@BlockedContactsActivity) } - .setNegativeButton(R.string.cancel) { _, _ -> } - .show() + showSessionDialog { + title(viewModel.getTitle(this@BlockedContactsActivity)) + text(viewModel.getMessage(this@BlockedContactsActivity)) + button(R.string.continue_2) { viewModel.unblock() } + cancelButton() + } } override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { @@ -51,4 +46,3 @@ class BlockedContactsActivity: PassphraseRequiredActionBarActivity() { } } - \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt index a75d53c4f..e0b92bdbe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt @@ -38,7 +38,7 @@ class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdap override fun onViewRecycled(holder: ViewHolder) { super.onViewRecycled(holder) - holder.binding.profilePictureView.root.recycle() + holder.binding.profilePictureView.recycle() } class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { @@ -48,8 +48,7 @@ class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdap fun bind(selectable: SelectableRecipient, toggle: (SelectableRecipient) -> Unit) { binding.recipientName.text = selectable.item.name - with (binding.profilePictureView.root) { - glide = this@ViewHolder.glide + with (binding.profilePictureView) { update(selectable.item) } binding.root.setOnClickListener { toggle(selectable) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsLayout.kt deleted file mode 100644 index ed2970fbc..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsLayout.kt +++ /dev/null @@ -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) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsPreference.kt index e985ba6d4..48c7cc6dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsPreference.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.preferences import android.content.Context import android.content.Intent import android.util.AttributeSet -import android.view.View import androidx.preference.PreferenceCategory import androidx.preference.PreferenceViewHolder @@ -16,8 +15,7 @@ class BlockedContactsPreference @JvmOverloads constructor( super.onBindViewHolder(holder) holder.itemView.setOnClickListener { - val intent = Intent(context, BlockedContactsActivity::class.java) - context.startActivity(intent) + Intent(context, BlockedContactsActivity::class.java).let(context::startActivity) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt index acbba1ebb..dbe09668c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt @@ -63,13 +63,9 @@ class BlockedContactsViewModel @Inject constructor(private val storage: Storage) return _state } - fun unblock(context: Context) { - storage.unblock(state.selectedItems) + fun unblock() { + storage.setBlocked(state.selectedItems, false) _state.value = state.copy(selectedItems = emptySet()) - // TODO: Remove in UserConfig branch - GlobalScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) - } } fun select(selectedItem: Recipient, isSelected: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/CallToggleListener.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/CallToggleListener.kt new file mode 100644 index 000000000..ea747798c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CallToggleListener.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChangeUiModeDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChangeUiModeDialog.kt deleted file mode 100644 index 3d5b9e2e9..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChangeUiModeDialog.kt +++ /dev/null @@ -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() - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt index fa3be7130..37a54a4af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt @@ -1,9 +1,12 @@ package org.thoughtcrime.securesms.preferences +import android.app.Dialog +import android.os.Bundle import android.view.LayoutInflater -import androidx.appcompat.app.AlertDialog +import android.view.View import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import kotlinx.coroutines.Dispatchers @@ -15,10 +18,10 @@ import network.loki.messenger.databinding.DialogClearAllDataBinding import org.session.libsession.snode.SnodeAPI import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities -class ClearAllDataDialog : BaseDialog() { +class ClearAllDataDialog : DialogFragment() { private lateinit var binding: DialogClearAllDataBinding enum class Steps { @@ -35,7 +38,11 @@ class ClearAllDataDialog : BaseDialog() { updateUI() } - override fun setContentView(builder: AlertDialog.Builder) { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { + view(createView()) + } + + private fun createView(): View { binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext())) val device = RadioOption("deviceOnly", requireContext().getString(R.string.dialog_clear_all_data_clear_device_only)) val network = RadioOption("deviceAndNetwork", requireContext().getString(R.string.dialog_clear_all_data_clear_device_and_network)) @@ -62,8 +69,7 @@ class ClearAllDataDialog : BaseDialog() { Steps.DELETING -> { /* do nothing intentionally */ } } } - builder.setView(binding.root) - builder.setCancelable(false) + return binding.root } private fun updateUI() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java index badcbe66b..8c3e6190e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java @@ -24,8 +24,6 @@ import androidx.recyclerview.widget.RecyclerView; import org.thoughtcrime.securesms.components.CustomDefaultPreference; import org.thoughtcrime.securesms.conversation.v2.ViewUtil; -import org.thoughtcrime.securesms.preferences.widgets.ColorPickerPreference; -import org.thoughtcrime.securesms.preferences.widgets.ColorPickerPreferenceDialogFragmentCompat; import network.loki.messenger.R; @@ -60,9 +58,7 @@ public abstract class CorrectedPreferenceFragment extends PreferenceFragmentComp public void onDisplayPreferenceDialog(Preference preference) { DialogFragment dialogFragment = null; - if (preference instanceof ColorPickerPreference) { - dialogFragment = ColorPickerPreferenceDialogFragmentCompat.newInstance(preference.getKey()); - } else if (preference instanceof CustomDefaultPreference) { + if (preference instanceof CustomDefaultPreference) { dialogFragment = CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.newInstance(preference.getKey()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ListPreferenceDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ListPreferenceDialog.kt index 2ba48f6e4..6f0998eec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ListPreferenceDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ListPreferenceDialog.kt @@ -1,41 +1,24 @@ package org.thoughtcrime.securesms.preferences import android.content.Context -import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog import androidx.preference.ListPreference -import network.loki.messenger.databinding.DialogListPreferenceBinding +import org.thoughtcrime.securesms.showSessionDialog fun listPreferenceDialog( context: Context, listPreference: ListPreference, - dialogListener: () -> Unit -) : AlertDialog { + onChange: () -> Unit +) : AlertDialog = listPreference.run { + context.showSessionDialog { + val index = entryValues.indexOf(value) + val options = entries.map(CharSequence::toString).toTypedArray() - val builder = AlertDialog.Builder(context) - - val binding = DialogListPreferenceBinding.inflate(LayoutInflater.from(context)) - binding.titleTextView.text = listPreference.dialogTitle - binding.messageTextView.text = listPreference.dialogMessage - - builder.setView(binding.root) - - val dialog = builder.show() - - val valueIndex = listPreference.findIndexOfValue(listPreference.value) - RadioOptionAdapter(valueIndex) { - listPreference.value = it.value - dialog.dismiss() - dialogListener() - } - .apply { - listPreference.entryValues.zip(listPreference.entries) { value, title -> - RadioOption(value.toString(), title.toString()) - }.let(this::submitList) + title(dialogTitle) + text(dialogMessage) + singleChoiceItems(options, index) { + listPreference.setValueIndex(it) + onChange() } - .let { binding.recyclerView.adapter = it } - - binding.closeButton.setOnClickListener { dialog.dismiss() } - - return dialog + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.java deleted file mode 100644 index ac03efa36..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.java +++ /dev/null @@ -1,203 +0,0 @@ -package org.thoughtcrime.securesms.preferences; - -import android.Manifest; -import android.app.Activity; -import android.app.AlertDialog; -import android.app.KeyguardManager; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.provider.Settings; -import android.widget.Button; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.view.ContextThemeWrapper; -import androidx.fragment.app.Fragment; -import androidx.preference.Preference; - -import org.session.libsession.utilities.TextSecurePreferences; -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; -import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.service.KeyCachingService; -import org.thoughtcrime.securesms.util.CallNotificationBuilder; -import org.thoughtcrime.securesms.util.IntentUtils; - -import kotlin.jvm.functions.Function1; -import network.loki.messenger.BuildConfig; -import network.loki.messenger.R; - -public class PrivacySettingsPreferenceFragment extends ListSummaryPreferenceFragment { - - @Override - public void onAttach(Activity activity) { - super.onAttach(activity); - } - - @Override - public void onCreate(Bundle paramBundle) { - super.onCreate(paramBundle); - - this.findPreference(TextSecurePreferences.SCREEN_LOCK).setOnPreferenceChangeListener(new ScreenLockListener()); - - this.findPreference(TextSecurePreferences.READ_RECEIPTS_PREF).setOnPreferenceChangeListener(new ReadReceiptToggleListener()); - this.findPreference(TextSecurePreferences.TYPING_INDICATORS).setOnPreferenceChangeListener(new TypingIndicatorsToggleListener()); - this.findPreference(TextSecurePreferences.LINK_PREVIEWS).setOnPreferenceChangeListener(new LinkPreviewToggleListener()); - this.findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED).setOnPreferenceChangeListener(new CallToggleListener(this, this::setCall)); - - initializeVisibility(); - } - - private Void setCall(boolean isEnabled) { - ((SwitchPreferenceCompat)findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED)).setChecked(isEnabled); - if (isEnabled && !CallNotificationBuilder.areNotificationsEnabled(requireActivity())) { - // show a dialog saying that calls won't work properly if you don't have notifications on at a system level - new AlertDialog.Builder(new ContextThemeWrapper(requireActivity(), R.style.ThemeOverlay_Session_AlertDialog)) - .setTitle(R.string.CallNotificationBuilder_system_notification_title) - .setMessage(R.string.CallNotificationBuilder_system_notification_message) - .setPositiveButton(R.string.activity_notification_settings_title, (d, w) -> { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { - Intent settingsIntent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID); - if (IntentUtils.isResolvable(requireContext(), settingsIntent)) { - startActivity(settingsIntent); - } - } else { - Intent settingsIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .setData(Uri.parse("package:"+BuildConfig.APPLICATION_ID)); - if (IntentUtils.isResolvable(requireContext(), settingsIntent)) { - startActivity(settingsIntent); - } - } - d.dismiss(); - }) - .setNeutralButton(R.string.dismiss, (d, w) -> { - // do nothing, user might have broken notifications - d.dismiss(); - }) - .show(); - } - return null; - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); - } - - @Override - public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { - addPreferencesFromResource(R.xml.preferences_app_protection); - } - - @Override - public void onResume() { - super.onResume(); - } - - private void initializeVisibility() { - if (TextSecurePreferences.isPasswordDisabled(getContext())) { - KeyguardManager keyguardManager = (KeyguardManager)getContext().getSystemService(Context.KEYGUARD_SERVICE); - if (!keyguardManager.isKeyguardSecure()) { - ((SwitchPreferenceCompat)findPreference(TextSecurePreferences.SCREEN_LOCK)).setChecked(false); - findPreference(TextSecurePreferences.SCREEN_LOCK).setEnabled(false); - } - } else { - findPreference(TextSecurePreferences.SCREEN_LOCK).setVisible(false); - findPreference(TextSecurePreferences.SCREEN_LOCK_TIMEOUT).setVisible(false); - } - } - - private class ScreenLockListener implements Preference.OnPreferenceChangeListener { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - boolean enabled = (Boolean)newValue; - - TextSecurePreferences.setScreenLockEnabled(getContext(), enabled); - - Intent intent = new Intent(getContext(), KeyCachingService.class); - intent.setAction(KeyCachingService.LOCK_TOGGLED_EVENT); - getContext().startService(intent); - return true; - } - } - - private class ReadReceiptToggleListener implements Preference.OnPreferenceChangeListener { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - return true; - } - } - - private class TypingIndicatorsToggleListener implements Preference.OnPreferenceChangeListener { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - boolean enabled = (boolean)newValue; - - if (!enabled) { - ApplicationContext.getInstance(requireContext()).getTypingStatusRepository().clear(); - } - - return true; - } - } - - private class LinkPreviewToggleListener implements Preference.OnPreferenceChangeListener { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - return true; - } - } - - private class CallToggleListener implements Preference.OnPreferenceChangeListener { - - private final Fragment context; - private final Function1 setCallback; - - private CallToggleListener(Fragment context, Function1 setCallback) { - this.context = context; - this.setCallback = setCallback; - } - - private void requestMicrophonePermission() { - Permissions.with(context) - .request(Manifest.permission.RECORD_AUDIO) - .onAllGranted(() -> { - TextSecurePreferences.setBooleanPreference(context.requireContext(), TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED, true); - setCallback.invoke(true); - }) - .onAnyDenied(() -> setCallback.invoke(false)) - .execute(); - } - - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - boolean val = (boolean) newValue; - if (val) { - // check if we've shown the info dialog and check for microphone permissions - AlertDialog dialog = new AlertDialog.Builder(new ContextThemeWrapper(context.requireContext(), R.style.ThemeOverlay_Session_AlertDialog)) - .setTitle(R.string.dialog_voice_video_title) - .setMessage(R.string.dialog_voice_video_message) - .setPositiveButton(R.string.dialog_link_preview_enable_button_title, (d, w) -> { - requestMicrophonePermission(); - }) - .setNegativeButton(R.string.cancel, (d, w) -> { - - }) - .show(); - Button positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE); - positiveButton.setContentDescription("Enable"); - return false; - } else { - return true; - } - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt new file mode 100644 index 000000000..eaf48f868 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt @@ -0,0 +1,114 @@ +package org.thoughtcrime.securesms.preferences + +import android.app.KeyguardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import androidx.preference.Preference +import network.loki.messenger.BuildConfig +import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.isPasswordDisabled +import org.session.libsession.utilities.TextSecurePreferences.Companion.setScreenLockEnabled +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.components.SwitchPreferenceCompat +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.service.KeyCachingService +import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.areNotificationsEnabled +import org.thoughtcrime.securesms.util.IntentUtils + +class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() { + override fun onCreate(paramBundle: Bundle?) { + super.onCreate(paramBundle) + findPreference(TextSecurePreferences.SCREEN_LOCK)!! + .onPreferenceChangeListener = ScreenLockListener() + findPreference(TextSecurePreferences.TYPING_INDICATORS)!! + .onPreferenceChangeListener = TypingIndicatorsToggleListener() + findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED)!! + .onPreferenceChangeListener = CallToggleListener(this) { setCall(it) } + initializeVisibility() + } + + private fun setCall(isEnabled: Boolean) { + (findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED) as SwitchPreferenceCompat?)!!.isChecked = + isEnabled + if (isEnabled && !areNotificationsEnabled(requireActivity())) { + // show a dialog saying that calls won't work properly if you don't have notifications on at a system level + showSessionDialog { + title(R.string.CallNotificationBuilder_system_notification_title) + text(R.string.CallNotificationBuilder_system_notification_message) + button(R.string.activity_notification_settings_title) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID) + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.parse("package:" + BuildConfig.APPLICATION_ID)) + } + .apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + .takeIf { IntentUtils.isResolvable(requireContext(), it) }.let { + startActivity(it) + } + } + button(R.string.dismiss) + } + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.preferences_app_protection) + } + + override fun onResume() { + super.onResume() + } + + private fun initializeVisibility() { + if (isPasswordDisabled(requireContext())) { + val keyguardManager = + requireContext().getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + if (!keyguardManager.isKeyguardSecure) { + findPreference(TextSecurePreferences.SCREEN_LOCK)!!.isChecked = false + findPreference(TextSecurePreferences.SCREEN_LOCK)!!.isEnabled = false + } + } else { + findPreference(TextSecurePreferences.SCREEN_LOCK)!!.isVisible = false + findPreference(TextSecurePreferences.SCREEN_LOCK_TIMEOUT)!!.isVisible = false + } + } + + private inner class ScreenLockListener : Preference.OnPreferenceChangeListener { + override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { + val enabled = newValue as Boolean + setScreenLockEnabled(context!!, enabled) + val intent = Intent(context, KeyCachingService::class.java) + intent.action = KeyCachingService.LOCK_TOGGLED_EVENT + context!!.startService(intent) + return true + } + } + + private inner class TypingIndicatorsToggleListener : Preference.OnPreferenceChangeListener { + override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { + val enabled = newValue as Boolean + if (!enabled) { + ApplicationContext.getInstance(requireContext()).typingStatusRepository.clear() + } + return true + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt index e7bfd60d3..bae5f1960 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt @@ -1,38 +1,34 @@ package org.thoughtcrime.securesms.preferences +import android.app.Dialog import android.content.ClipData import android.content.ClipboardManager import android.content.Context -import android.view.LayoutInflater +import android.os.Bundle import android.widget.Toast -import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment import network.loki.messenger.R -import network.loki.messenger.databinding.DialogSeedBinding import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.hexEncodedPrivateKey +import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.MnemonicUtilities -import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog - -class SeedDialog : BaseDialog() { +class SeedDialog: DialogFragment() { private val seed by lazy { - var hexEncodedSeed = IdentityKeyUtil.retrieve(requireContext(), IdentityKeyUtil.LOKI_SEED) - if (hexEncodedSeed == null) { - hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(requireContext()).hexEncodedPrivateKey // Legacy account - } - val loadFileContents: (String) -> String = { fileName -> - MnemonicUtilities.loadFileContents(requireContext(), fileName) - } - MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english) + val hexEncodedSeed = IdentityKeyUtil.retrieve(requireContext(), IdentityKeyUtil.LOKI_SEED) + ?: IdentityKeyUtil.getIdentityKeyPair(requireContext()).hexEncodedPrivateKey // Legacy account + + MnemonicCodec { fileName -> MnemonicUtilities.loadFileContents(requireContext(), fileName) } + .encode(hexEncodedSeed, MnemonicCodec.Language.Configuration.english) } - override fun setContentView(builder: AlertDialog.Builder) { - val binding = DialogSeedBinding.inflate(LayoutInflater.from(requireContext())) - binding.seedTextView.text = seed - binding.closeButton.setOnClickListener { dismiss() } - binding.copyButton.setOnClickListener { copySeed() } - builder.setView(binding.root) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { + title(R.string.dialog_seed_title) + text(R.string.dialog_seed_explanation) + text(seed, R.style.SessionIDTextView) + button(R.string.copy, R.string.AccessibilityId_copy_recovery_phrase) { copySeed() } + button(R.string.close) { dismiss() } } private fun copySeed() { @@ -42,4 +38,4 @@ class SeedDialog : BaseDialog() { Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() dismiss() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 5a03cebc3..5f2485576 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -2,7 +2,10 @@ package org.thoughtcrime.securesms.preferences import android.Manifest import android.app.Activity -import android.content.* +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent import android.net.Uri import android.os.AsyncTask import android.os.Bundle @@ -16,16 +19,19 @@ import android.view.MenuItem import android.view.View import android.view.inputmethod.InputMethodManager import android.widget.Toast -import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.databinding.ActivitySettingsBinding +import network.loki.messenger.libsession_util.util.UserPic import nl.komponents.kovenant.Promise import nl.komponents.kovenant.all import nl.komponents.kovenant.ui.alwaysUi import nl.komponents.kovenant.ui.successUi import org.session.libsession.avatars.AvatarHelper +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.snode.SnodeAPI import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.utilities.* import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol @@ -33,6 +39,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.avatar.AvatarSelection import org.thoughtcrime.securesms.components.ProfilePictureView +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.home.PathActivity import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.mms.GlideApp @@ -40,6 +47,7 @@ import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints +import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.BitmapDecodingException import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities @@ -48,9 +56,14 @@ import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show import java.io.File import java.security.SecureRandom -import java.util.Date +import javax.inject.Inject +@AndroidEntryPoint class SettingsActivity : PassphraseRequiredActionBarActivity() { + + @Inject + lateinit var configFactory: ConfigFactory + private lateinit var binding: ActivitySettingsBinding private var displayNameEditActionMode: ActionMode? = null set(value) { field = value; handleDisplayNameEditActionModeChanged() } @@ -75,8 +88,8 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { val displayName = getDisplayName() glide = GlideApp.with(this) with(binding) { - setupProfilePictureView(profilePictureView.root) - profilePictureView.root.setOnClickListener { showEditProfilePictureUI() } + setupProfilePictureView(profilePictureView) + profilePictureView.setOnClickListener { showEditProfilePictureUI() } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } btnGroupNameDisplay.text = displayName publicKeyTextView.text = hexEncodedPublicKey @@ -101,7 +114,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(hexEncodedPublicKey) private fun setupProfilePictureView(view: ProfilePictureView) { - view.glide = glide view.apply { publicKey = hexEncodedPublicKey displayName = getDisplayName() @@ -204,35 +216,44 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { val promises = mutableListOf>() if (displayName != null) { TextSecurePreferences.setProfileName(this, displayName) + configFactory.user?.setName(displayName) } val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(this) if (isUpdatingProfilePicture) { if (profilePicture != null) { promises.add(ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this)) } else { - TextSecurePreferences.setLastProfilePictureUpload(this, System.currentTimeMillis()) - TextSecurePreferences.setProfilePictureURL(this, null) + MessagingModuleConfiguration.shared.storage.clearUserPic() } } val compoundPromise = all(promises) compoundPromise.successUi { // Do this on the UI thread so that it happens before the alwaysUi clause below + val userConfig = configFactory.user if (isUpdatingProfilePicture) { AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture) TextSecurePreferences.setProfileAvatarId(this, profilePicture?.let { SecureRandom().nextInt() } ?: 0 ) - TextSecurePreferences.setLastProfilePictureUpload(this, Date().time) ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey) + // new config + val url = TextSecurePreferences.getProfilePictureURL(this) + val profileKey = ProfileKeyUtil.getProfileKey(this) + if (profilePicture == null) { + userConfig?.setPic(UserPic.DEFAULT) + } else if (!url.isNullOrEmpty() && profileKey.isNotEmpty()) { + userConfig?.setPic(UserPic(url, profileKey)) + } } - if (profilePicture != null || displayName != null) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity) + if (userConfig != null && userConfig.needsDump()) { + configFactory.persist(userConfig, SnodeAPI.nowWithOffset) } + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity) } compoundPromise.alwaysUi { if (displayName != null) { binding.btnGroupNameDisplay.text = displayName } if (isUpdatingProfilePicture) { - binding.profilePictureView.root.recycle() // Clear the cached image before updating - binding.profilePictureView.root.update() + binding.profilePictureView.recycle() // Clear the cached image before updating + binding.profilePictureView.update() } binding.loader.isVisible = false } @@ -264,19 +285,15 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } private fun showEditProfilePictureUI() { - AlertDialog.Builder(this) - .setTitle(R.string.activity_settings_set_display_picture) - .setView(R.layout.dialog_change_avatar) - .setPositiveButton(R.string.activity_settings_upload) { _, _ -> - startAvatarSelection() + showSessionDialog { + title(R.string.activity_settings_set_display_picture) + view(R.layout.dialog_change_avatar) + button(R.string.activity_settings_upload) { startAvatarSelection() } + if (TextSecurePreferences.getProfileAvatarId(context) != 0) { + button(R.string.activity_settings_remove) { removeAvatar() } } - .setNegativeButton(R.string.cancel) { _, _ -> } - .apply { - if (TextSecurePreferences.getProfileAvatarId(context) != 0) { - setNeutralButton(R.string.activity_settings_remove) { _, _ -> removeAvatar() } - } - } - .show().apply { + cancelButton() + }.apply { val profilePic = findViewById(R.id.profile_picture_view) ?.also(::setupProfilePictureView) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt index 1bd837324..e3be429e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt @@ -1,17 +1,18 @@ package org.thoughtcrime.securesms.preferences +import android.app.Dialog import android.content.ContentResolver import android.content.ContentValues import android.content.Intent import android.media.MediaScannerConnection import android.net.Uri import android.os.Build +import android.os.Bundle import android.os.Environment import android.provider.MediaStore -import android.view.LayoutInflater import android.webkit.MimeTypeMap import android.widget.Toast -import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.Main @@ -20,11 +21,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.BuildConfig import network.loki.messenger.R -import network.loki.messenger.databinding.DialogShareLogsBinding import org.session.libsignal.utilities.ExternalStorageUtil import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.util.FileProviderUtil import org.thoughtcrime.securesms.util.StreamUtil import java.io.File @@ -33,21 +33,15 @@ import java.io.IOException import java.util.Objects import java.util.concurrent.TimeUnit -class ShareLogsDialog : BaseDialog() { +class ShareLogsDialog : DialogFragment() { private var shareJob: Job? = null - override fun setContentView(builder: AlertDialog.Builder) { - val binding = DialogShareLogsBinding.inflate(LayoutInflater.from(requireContext())) - binding.cancelButton.setOnClickListener { - dismiss() - } - binding.shareButton.setOnClickListener { - // start the export and share - shareLogs() - } - builder.setView(binding.root) - builder.setCancelable(false) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { + title(R.string.dialog_share_logs_title) + text(R.string.dialog_share_logs_explanation) + button(R.string.share) { shareLogs() } + cancelButton { dismiss() } } private fun shareLogs() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java deleted file mode 100644 index 1cccf1d52..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java +++ /dev/null @@ -1,251 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Color; -import android.graphics.drawable.Drawable; -import androidx.core.content.ContextCompat; -import androidx.core.content.res.TypedArrayUtils; -import androidx.preference.DialogPreference; -import androidx.preference.PreferenceViewHolder; -import android.text.TextUtils; -import android.util.AttributeSet; -import android.widget.ImageView; - -import com.takisoft.colorpicker.ColorPickerDialog; -import com.takisoft.colorpicker.ColorPickerDialog.Size; -import com.takisoft.colorpicker.ColorStateDrawable; - -import network.loki.messenger.R; - -public class ColorPickerPreference extends DialogPreference { - - private static final String TAG = ColorPickerPreference.class.getSimpleName(); - - private int[] colors; - private CharSequence[] colorDescriptions; - private int color; - private int columns; - private int size; - private boolean sortColors; - - private ImageView colorWidget; - private OnPreferenceChangeListener listener; - - public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - - TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ColorPickerPreference, defStyleAttr, 0); - - int colorsId = a.getResourceId(R.styleable.ColorPickerPreference_colors, R.array.color_picker_default_colors); - - if (colorsId != 0) { - colors = context.getResources().getIntArray(colorsId); - } - - colorDescriptions = a.getTextArray(R.styleable.ColorPickerPreference_colorDescriptions); - color = a.getColor(R.styleable.ColorPickerPreference_currentColor, 0); - columns = a.getInt(R.styleable.ColorPickerPreference_columns, 3); - size = a.getInt(R.styleable.ColorPickerPreference_colorSize, 2); - sortColors = a.getBoolean(R.styleable.ColorPickerPreference_sortColors, false); - - a.recycle(); - - setWidgetLayoutResource(R.layout.preference_widget_color_swatch); - } - - public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr) { - this(context, attrs, defStyleAttr, 0); - } - - @SuppressLint("RestrictedApi") - public ColorPickerPreference(Context context, AttributeSet attrs) { - this(context, attrs, TypedArrayUtils.getAttr(context, R.attr.dialogPreferenceStyle, - android.R.attr.dialogPreferenceStyle)); - } - - public ColorPickerPreference(Context context) { - this(context, null); - } - - @Override - public void setOnPreferenceChangeListener(OnPreferenceChangeListener listener) { - super.setOnPreferenceChangeListener(listener); - this.listener = listener; - } - - @Override - public void onBindViewHolder(PreferenceViewHolder holder) { - super.onBindViewHolder(holder); - - colorWidget = (ImageView) holder.findViewById(R.id.color_picker_widget); - setColorOnWidget(color); - } - - private void setColorOnWidget(int color) { - if (colorWidget == null) { - return; - } - - Drawable[] colorDrawable = new Drawable[] - {ContextCompat.getDrawable(getContext(), R.drawable.colorpickerpreference_pref_swatch)}; - colorWidget.setImageDrawable(new ColorStateDrawable(colorDrawable, color)); - } - - /** - * Returns the current color. - * - * @return The current color. - */ - public int getColor() { - return color; - } - - /** - * Sets the current color. - * - * @param color The current color. - */ - public void setColor(int color) { - setInternalColor(color, false); - } - - /** - * Returns all of the available colors. - * - * @return The available colors. - */ - public int[] getColors() { - return colors; - } - - /** - * Sets the available colors. - * - * @param colors The available colors. - */ - public void setColors(int[] colors) { - this.colors = colors; - } - - /** - * Returns whether the available colors should be sorted automatically based on their HSV - * values. - * - * @return Whether the available colors should be sorted automatically based on their HSV - * values. - */ - public boolean isSortColors() { - return sortColors; - } - - /** - * Sets whether the available colors should be sorted automatically based on their HSV - * values. The sorting does not modify the order of the original colors supplied via - * {@link #setColors(int[])} or the XML attribute {@code app:colors}. - * - * @param sortColors Whether the available colors should be sorted automatically based on their - * HSV values. - */ - public void setSortColors(boolean sortColors) { - this.sortColors = sortColors; - } - - /** - * Returns the available colors' descriptions that can be used by accessibility services. - * - * @return The available colors' descriptions. - */ - public CharSequence[] getColorDescriptions() { - return colorDescriptions; - } - - /** - * Sets the available colors' descriptions that can be used by accessibility services. - * - * @param colorDescriptions The available colors' descriptions. - */ - public void setColorDescriptions(CharSequence[] colorDescriptions) { - this.colorDescriptions = colorDescriptions; - } - - /** - * Returns the number of columns to be used in the picker dialog for displaying the available - * colors. If the value is less than or equals to 0, the number of columns will be determined - * automatically by the system using FlexboxLayoutManager. - * - * @return The number of columns to be used in the picker dialog. - * @see com.google.android.flexbox.FlexboxLayoutManager - */ - public int getColumns() { - return columns; - } - - /** - * Sets the number of columns to be used in the picker dialog for displaying the available - * colors. If the value is less than or equals to 0, the number of columns will be determined - * automatically by the system using FlexboxLayoutManager. - * - * @param columns The number of columns to be used in the picker dialog. Use 0 to set it to - * 'auto' mode. - * @see com.google.android.flexbox.FlexboxLayoutManager - */ - public void setColumns(int columns) { - this.columns = columns; - } - - /** - * Returns the size of the color swatches in the dialog. It can be either - * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. - * - * @return The size of the color swatches in the dialog. - * @see ColorPickerDialog#SIZE_SMALL - * @see ColorPickerDialog#SIZE_LARGE - */ - @Size - public int getSize() { - return size; - } - - /** - * Sets the size of the color swatches in the dialog. It can be either - * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. - * - * @param size The size of the color swatches in the dialog. It can be either - * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. - * @see ColorPickerDialog#SIZE_SMALL - * @see ColorPickerDialog#SIZE_LARGE - */ - public void setSize(@Size int size) { - this.size = size; - } - - private void setInternalColor(int color, boolean force) { - int oldColor = getPersistedInt(0); - - boolean changed = oldColor != color; - - if (changed || force) { - this.color = color; - - persistInt(color); - - setColorOnWidget(color); - - if (listener != null) listener.onPreferenceChange(this, color); - notifyChanged(); - } - } - - @Override - protected Object onGetDefaultValue(TypedArray a, int index) { - return a.getString(index); - } - - @Override - protected void onSetInitialValue(boolean restoreValue, Object defaultValueObj) { - final String defaultValue = (String) defaultValueObj; - setInternalColor(restoreValue ? getPersistedInt(0) : (!TextUtils.isEmpty(defaultValue) ? Color.parseColor(defaultValue) : 0), true); - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java deleted file mode 100644 index 964f439ba..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets; - -import android.app.Dialog; -import android.content.DialogInterface; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.preference.PreferenceDialogFragmentCompat; - -import com.takisoft.colorpicker.ColorPickerDialog; -import com.takisoft.colorpicker.OnColorSelectedListener; - -public class ColorPickerPreferenceDialogFragmentCompat extends PreferenceDialogFragmentCompat implements OnColorSelectedListener { - - private int pickedColor; - - public static ColorPickerPreferenceDialogFragmentCompat newInstance(String key) { - ColorPickerPreferenceDialogFragmentCompat fragment = new ColorPickerPreferenceDialogFragmentCompat(); - Bundle b = new Bundle(1); - b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key); - fragment.setArguments(b); - return fragment; - } - - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - ColorPickerPreference pref = getColorPickerPreference(); - - ColorPickerDialog.Params params = new ColorPickerDialog.Params.Builder(getContext()) - .setSelectedColor(pref.getColor()) - .setColors(pref.getColors()) - .setColorContentDescriptions(pref.getColorDescriptions()) - .setSize(pref.getSize()) - .setSortColors(pref.isSortColors()) - .setColumns(pref.getColumns()) - .build(); - - ColorPickerDialog dialog = new ColorPickerDialog(getActivity(), this, params); - dialog.setTitle(pref.getDialogTitle()); - - return dialog; - } - - @Override - public void onDialogClosed(boolean positiveResult) { - ColorPickerPreference preference = getColorPickerPreference(); - - if (positiveResult) { - preference.setColor(pickedColor); - } - } - - @Override - public void onColorSelected(int color) { - this.pickedColor = color; - - super.onClick(getDialog(), DialogInterface.BUTTON_POSITIVE); - } - - ColorPickerPreference getColorPickerPreference() { - return (ColorPickerPreference) getPreference(); - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java index f1cbea16c..1c05e68bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -144,7 +144,6 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter - suspend fun clearAllMessageRequests(): ResultOf + suspend fun clearAllMessageRequests(block: Boolean): ResultOf suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf @@ -82,8 +84,10 @@ class DefaultConversationRepository @Inject constructor( private val mmsDb: MmsDatabase, private val mmsSmsDb: MmsSmsDatabase, private val recipientDb: RecipientDatabase, + private val storage: Storage, private val lokiMessageDb: LokiMessageDatabase, - private val sessionJobDb: SessionJobDatabase + private val sessionJobDb: SessionJobDatabase, + private val configFactory: ConfigFactory ) : ConversationRepository { override fun maybeGetRecipientForThreadId(threadId: Long): Recipient? { @@ -125,8 +129,9 @@ class DefaultConversationRepository @Inject constructor( } } + // This assumes that recipient.isContactRecipient is true override fun setBlocked(recipient: Recipient, blocked: Boolean) { - recipientDb.setBlocked(recipient, blocked) + storage.setBlocked(listOf(recipient), blocked) } override fun deleteLocally(recipient: Recipient, message: MessageRecord) { @@ -139,7 +144,7 @@ class DefaultConversationRepository @Inject constructor( } override fun setApproved(recipient: Recipient, isApproved: Boolean) { - recipientDb.setApproved(recipient, isApproved) + storage.setRecipientApproved(recipient, isApproved) } override suspend fun deleteForEveryone( @@ -250,29 +255,33 @@ class DefaultConversationRepository @Inject constructor( override suspend fun deleteThread(threadId: Long): ResultOf { sessionJobDb.cancelPendingMessageSendJobs(threadId) - threadDb.deleteConversation(threadId) + storage.deleteConversation(threadId) return ResultOf.Success(Unit) } override suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf { sessionJobDb.cancelPendingMessageSendJobs(thread.threadId) - threadDb.deleteConversation(thread.threadId) + storage.deleteConversation(thread.threadId) return ResultOf.Success(Unit) } - override suspend fun clearAllMessageRequests(): ResultOf { + override suspend fun clearAllMessageRequests(block: Boolean): ResultOf { threadDb.readerFor(threadDb.unapprovedConversationList).use { reader -> while (reader.next != null) { deleteMessageRequest(reader.current) + val recipient = reader.current.recipient + if (block) { + setBlocked(recipient, true) + } } } return ResultOf.Success(Unit) } override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf = suspendCoroutine { continuation -> - recipientDb.setApproved(recipient, true) + storage.setRecipientApproved(recipient, true) val message = MessageRequestResponse(true) - MessageSender.send(message, Destination.from(recipient.address)) + MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber) .success { threadDb.setHasSent(threadId, true) continuation.resume(ResultOf.Success(Unit)) @@ -283,7 +292,7 @@ class DefaultConversationRepository @Inject constructor( override fun declineMessageRequest(threadId: Long) { sessionJobDb.cancelPendingMessageSendJobs(threadId) - threadDb.deleteConversation(threadId) + storage.deleteConversation(threadId) } override fun hasReceived(threadId: Long): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java index f42b55b5f..85d8c8f43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java @@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.service; import android.content.Context; import org.jetbrains.annotations.NotNull; +import org.session.libsession.database.StorageProtocol; +import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate; import org.session.libsession.messaging.messages.signal.IncomingMediaMessage; import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage; @@ -15,6 +17,7 @@ import org.session.libsignal.messages.SignalServiceGroup; import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; @@ -35,12 +38,14 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM private final SmsDatabase smsDatabase; private final MmsDatabase mmsDatabase; + private final MmsSmsDatabase mmsSmsDatabase; private final Context context; public ExpiringMessageManager(Context context) { this.context = context.getApplicationContext(); this.smsDatabase = DatabaseComponent.get(context).smsDatabase(); this.mmsDatabase = DatabaseComponent.get(context).mmsDatabase(); + this.mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); executor.execute(new LoadTask()); executor.execute(new ProcessTask()); @@ -79,12 +84,11 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM } if (message.getId() != null) { - DatabaseComponent.get(context).smsDatabase().deleteMessage(message.getId()); + smsDatabase.deleteMessage(message.getId()); } } private void insertIncomingExpirationTimerMessage(ExpirationTimerUpdate message) { - MmsDatabase database = DatabaseComponent.get(context).mmsDatabase(); String senderPublicKey = message.getSender(); Long sentTimestamp = message.getSentTimestamp(); @@ -106,6 +110,10 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM Address groupAddress = Address.fromSerialized(groupID); recipient = Recipient.from(context, groupAddress, false); } + Long threadId = MessagingModuleConfiguration.getShared().getStorage().getThreadId(recipient); + if (threadId == null) { + return; + } IncomingMediaMessage mediaMessage = new IncomingMediaMessage(address, sentTimestamp, -1, duration * 1000L, true, @@ -120,10 +128,10 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM Optional.absent(), Optional.absent()); //insert the timer update message - database.insertSecureDecryptedMessageInbox(mediaMessage, -1, true, true); + mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, true); //set the timer to the conversation - DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); + MessagingModuleConfiguration.getShared().getStorage().setExpirationTimer(recipient.getAddress().serialize(), duration); } catch (IOException | MmsException ioe) { Log.e("Loki", "Failed to insert expiration update message."); @@ -131,28 +139,30 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM } private void insertOutgoingExpirationTimerMessage(ExpirationTimerUpdate message) { - MmsDatabase database = DatabaseComponent.get(context).mmsDatabase(); Long sentTimestamp = message.getSentTimestamp(); String groupId = message.getGroupPublicKey(); int duration = message.getDuration(); - Address address = Address.fromSerialized((message.getSyncTarget() != null && !message.getSyncTarget().isEmpty()) ? message.getSyncTarget() : message.getRecipient()); - Recipient recipient = Recipient.from(context, address, false); + Address address; try { - OutgoingExpirationUpdateMessage timerUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, sentTimestamp, duration * 1000L, groupId); - database.insertSecureDecryptedMessageOutbox(timerUpdateMessage, -1, sentTimestamp, true); - if (groupId != null) { - // we need the group ID as recipient for setExpireMessages below - recipient = Recipient.from(context, Address.fromSerialized(GroupUtil.doubleEncodeGroupID(groupId)), false); + address = Address.fromSerialized(GroupUtil.doubleEncodeGroupID(groupId)); + } else { + address = Address.fromSerialized((message.getSyncTarget() != null && !message.getSyncTarget().isEmpty()) ? message.getSyncTarget() : message.getRecipient()); } - //set the timer to the conversation - DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); + Recipient recipient = Recipient.from(context, address, false); + StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage(); + message.setThreadID(storage.getOrCreateThreadIdFor(address)); + + OutgoingExpirationUpdateMessage timerUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, sentTimestamp, duration * 1000L, groupId); + mmsDatabase.insertSecureDecryptedMessageOutbox(timerUpdateMessage, message.getThreadID(), sentTimestamp, true); + //set the timer to the conversation + MessagingModuleConfiguration.getShared().getStorage().setExpirationTimer(recipient.getAddress().serialize(), duration); } catch (MmsException | IOException ioe) { - Log.e("Loki", "Failed to insert expiration update message."); + Log.e("Loki", "Failed to insert expiration update message.", ioe); } } @@ -163,7 +173,7 @@ public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationM @Override public void startAnyExpiration(long timestamp, @NotNull String author) { - MessageRecord messageRecord = DatabaseComponent.get(context).mmsSmsDatabase().getMessageFor(timestamp, author); + MessageRecord messageRecord = mmsSmsDatabase.getMessageFor(timestamp, author); if (messageRecord != null) { boolean mms = messageRecord.isMms(); Recipient recipient = messageRecord.getRecipient(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt index f9f5524ef..8b1975865 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt @@ -1,16 +1,23 @@ package org.thoughtcrime.securesms.sskenvironment import android.content.Context +import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.utilities.SSKEnvironment -import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob +import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.IdPrefix +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities -class ProfileManager : SSKEnvironment.ProfileManagerProtocol { +class ProfileManager(private val context: Context, private val configFactory: ConfigFactory) : SSKEnvironment.ProfileManagerProtocol { override fun setNickname(context: Context, recipient: Recipient, nickname: String?) { + if (recipient.isLocalNumber) return val sessionID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() var contact = contactDatabase.getContactWithSessionID(sessionID) @@ -20,10 +27,12 @@ class ProfileManager : SSKEnvironment.ProfileManagerProtocol { contact.nickname = nickname contactDatabase.setContact(contact) } + contactUpdatedInternal(contact) } - override fun setName(context: Context, recipient: Recipient, name: String) { + override fun setName(context: Context, recipient: Recipient, name: String?) { // New API + if (recipient.isLocalNumber) return val sessionID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() var contact = contactDatabase.getContactWithSessionID(sessionID) @@ -37,40 +46,69 @@ class ProfileManager : SSKEnvironment.ProfileManagerProtocol { val database = DatabaseComponent.get(context).recipientDatabase() database.setProfileName(recipient, name) recipient.notifyListeners() + contactUpdatedInternal(contact) } - override fun setProfilePictureURL(context: Context, recipient: Recipient, profilePictureURL: String) { - val job = RetrieveProfileAvatarJob(profilePictureURL, recipient.address) - JobQueue.shared.add(job) + override fun setProfilePicture( + context: Context, + recipient: Recipient, + profilePictureURL: String?, + profileKey: ByteArray? + ) { + val hasPendingDownload = DatabaseComponent + .get(context) + .sessionJobDatabase() + .getAllJobs(RetrieveProfileAvatarJob.KEY).any { + (it.value as? RetrieveProfileAvatarJob)?.recipientAddress == recipient.address + } + val resolved = recipient.resolve() + DatabaseComponent.get(context).storage().setProfilePicture( + recipient = resolved, + newProfileKey = profileKey, + newProfilePicture = profilePictureURL + ) val sessionID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() var contact = contactDatabase.getContactWithSessionID(sessionID) if (contact == null) contact = Contact(sessionID) contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) - if (contact.profilePictureURL != profilePictureURL) { + if (!contact.profilePictureEncryptionKey.contentEquals(profileKey) || contact.profilePictureURL != profilePictureURL) { + contact.profilePictureEncryptionKey = profileKey contact.profilePictureURL = profilePictureURL contactDatabase.setContact(contact) } - } - - override fun setProfileKey(context: Context, recipient: Recipient, profileKey: ByteArray) { - // New API - val sessionID = recipient.address.serialize() - val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() - var contact = contactDatabase.getContactWithSessionID(sessionID) - if (contact == null) contact = Contact(sessionID) - contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) - if (!contact.profilePictureEncryptionKey.contentEquals(profileKey)) { - contact.profilePictureEncryptionKey = profileKey - contactDatabase.setContact(contact) + contactUpdatedInternal(contact) + if (!hasPendingDownload) { + val job = RetrieveProfileAvatarJob(profilePictureURL, recipient.address) + JobQueue.shared.add(job) } - // Old API - val database = DatabaseComponent.get(context).recipientDatabase() - database.setProfileKey(recipient, profileKey) } override fun setUnidentifiedAccessMode(context: Context, recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode) { val database = DatabaseComponent.get(context).recipientDatabase() database.setUnidentifiedAccessMode(recipient, unidentifiedAccessMode) } + + override fun contactUpdatedInternal(contact: Contact): String? { + val contactConfig = configFactory.contacts ?: return null + if (contact.sessionID == TextSecurePreferences.getLocalNumber(context)) return null + val sessionId = SessionId(contact.sessionID) + if (sessionId.prefix != IdPrefix.STANDARD) return null // only internally store standard session IDs + contactConfig.upsertContact(contact.sessionID) { + this.name = contact.name.orEmpty() + this.nickname = contact.nickname.orEmpty() + val url = contact.profilePictureURL + val key = contact.profilePictureEncryptionKey + if (!url.isNullOrEmpty() && key != null && key.size == 32) { + this.profilePicture = UserPic(url, key) + } else if (url.isNullOrEmpty() && key == null) { + this.profilePicture = UserPic.DEFAULT + } + } + if (contactConfig.needsPush()) { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + return contactConfig.get(contact.sessionID)?.hashCode()?.toString() + } + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Colors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Colors.kt new file mode 100644 index 000000000..55bc1be62 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Colors.kt @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.ui + +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Colors +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +val colorDestructive = Color(0xffFF453A) + +const val classicDark0 = 0xff111111 +const val classicDark1 = 0xff1B1B1B +const val classicDark2 = 0xff2D2D2D +const val classicDark3 = 0xff414141 +const val classicDark4 = 0xff767676 +const val classicDark5 = 0xffA1A2A1 +const val classicDark6 = 0xffFFFFFF + +const val classicLight0 = 0xff000000 +const val classicLight1 = 0xff6D6D6D +const val classicLight2 = 0xffA1A2A1 +const val classicLight3 = 0xffDFDFDF +const val classicLight4 = 0xffF0F0F0 +const val classicLight5 = 0xffF9F9F9 +const val classicLight6 = 0xffFFFFFF + +const val oceanDark0 = 0xff000000 +const val oceanDark1 = 0xff1A1C28 +const val oceanDark2 = 0xff252735 +const val oceanDark3 = 0xff2B2D40 +const val oceanDark4 = 0xff3D4A5D +const val oceanDark5 = 0xffA6A9CE +const val oceanDark6 = 0xff5CAACC +const val oceanDark7 = 0xffFFFFFF + +const val oceanLight0 = 0xff000000 +const val oceanLight1 = 0xff19345D +const val oceanLight2 = 0xff6A6E90 +const val oceanLight3 = 0xff5CAACC +const val oceanLight4 = 0xffB3EDF2 +const val oceanLight5 = 0xffE7F3F4 +const val oceanLight6 = 0xffECFAFB +const val oceanLight7 = 0xffFCFFFF + +val ocean_accent = Color(0xff57C9FA) + +val oceanLights = arrayOf(oceanLight0, oceanLight1, oceanLight2, oceanLight3, oceanLight4, oceanLight5, oceanLight6, oceanLight7) +val oceanDarks = arrayOf(oceanDark0, oceanDark1, oceanDark2, oceanDark3, oceanDark4, oceanDark5, oceanDark6, oceanDark7) +val classicLights = arrayOf(classicLight0, classicLight1, classicLight2, classicLight3, classicLight4, classicLight5, classicLight6) +val classicDarks = arrayOf(classicDark0, classicDark1, classicDark2, classicDark3, classicDark4, classicDark5, classicDark6) + +val oceanLightColors = oceanLights.map(::Color) +val oceanDarkColors = oceanDarks.map(::Color) +val classicLightColors = classicLights.map(::Color) +val classicDarkColors = classicDarks.map(::Color) + +val blackAlpha40 = Color.Black.copy(alpha = 0.4f) + +@Composable +fun transparentButtonColors() = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent) + +@Composable +fun destructiveButtonColors() = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent, contentColor = colorDestructive) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt new file mode 100644 index 000000000..1724bde8a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -0,0 +1,182 @@ +package org.thoughtcrime.securesms.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ButtonColors +import androidx.compose.material.Card +import androidx.compose.material.Colors +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.google.accompanist.pager.HorizontalPagerIndicator +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.components.ProfilePictureView + +@Composable +fun ItemButton( + text: String, + @DrawableRes icon: Int, + colors: ButtonColors = transparentButtonColors(), + contentDescription: String = text, + onClick: () -> Unit +) { + TextButton( + modifier = Modifier + .fillMaxWidth() + .height(60.dp), + colors = colors, + onClick = onClick, + shape = RectangleShape, + ) { + Box(modifier = Modifier + .width(80.dp) + .fillMaxHeight()) { + Icon( + painter = painterResource(id = icon), + contentDescription = contentDescription, + modifier = Modifier.align(Alignment.Center) + ) + } + Text(text, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +fun Cell(content: @Composable () -> Unit) { + CellWithPaddingAndMargin(padding = 0.dp) { content() } +} +@Composable +fun CellNoMargin(content: @Composable () -> Unit) { + CellWithPaddingAndMargin(padding = 0.dp, margin = 0.dp) { content() } +} + +@Composable +fun CellWithPaddingAndMargin( + padding: Dp = 24.dp, + margin: Dp = 32.dp, + content: @Composable () -> Unit +) { + Card( + backgroundColor = MaterialTheme.colors.cellColor, + shape = RoundedCornerShape(16.dp), + elevation = 0.dp, + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(horizontal = margin), + ) { + Box(Modifier.padding(padding)) { content() } + } +} + +private val Colors.cellColor: Color + @Composable + get() = LocalExtraColors.current.settingsBackground + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun BoxScope.HorizontalPagerIndicator(pagerState: PagerState) { + if (pagerState.pageCount >= 2) Card( + shape = RoundedCornerShape(50.dp), + backgroundColor = Color.Black.copy(alpha = 0.4f), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(8.dp) + ) { + Box(modifier = Modifier.padding(8.dp)) { + HorizontalPagerIndicator( + pagerState = pagerState, + pageCount = pagerState.pageCount, + activeColor = Color.White, + inactiveColor = classicDarkColors[5]) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RowScope.CarouselPrevButton(pagerState: PagerState) { + CarouselButton(pagerState, pagerState.canScrollBackward, R.drawable.ic_prev, -1) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RowScope.CarouselNextButton(pagerState: PagerState) { + CarouselButton(pagerState, pagerState.canScrollForward, R.drawable.ic_next, 1) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RowScope.CarouselButton( + pagerState: PagerState, + enabled: Boolean, + @DrawableRes id: Int, + delta: Int +) { + if (pagerState.pageCount <= 1) Spacer(modifier = Modifier.width(32.dp)) + else { + val animationScope = rememberCoroutineScope() + IconButton( + modifier = Modifier + .width(40.dp) + .align(Alignment.CenterVertically), + enabled = enabled, + onClick = { animationScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + delta) } }) { + Icon( + painter = painterResource(id = id), + contentDescription = "", + ) + } + } +} + +@Composable +fun Divider() { + androidx.compose.material.Divider( + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +fun RowScope.Avatar(recipient: Recipient) { + Box( + modifier = Modifier + .width(60.dp) + .align(Alignment.CenterVertically) + ) { + AndroidView( + factory = { + ProfilePictureView(it).apply { update(recipient) } + }, + modifier = Modifier + .width(46.dp) + .height(46.dp) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt new file mode 100644 index 000000000..44ff4a42d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.ui + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +/** + * Compatibility class to allow ViewModels to use strings and string resources interchangeably. + */ +sealed class GetString { + @Composable + abstract fun string(): String + data class FromString(val string: String): GetString() { + @Composable + override fun string(): String = string + } + data class FromResId(@StringRes val resId: Int): GetString() { + @Composable + override fun string(): String = stringResource(resId) + + } +} + +fun GetString(@StringRes resId: Int) = GetString.FromResId(resId) +fun GetString(string: String) = GetString.FromString(string) + + +/** + * Represents some text with an associated title. + */ +data class TitledText(val title: GetString, val text: String) { + constructor(title: String, text: String): this(GetString(title), text) + constructor(@StringRes title: Int, text: String): this(GetString(title), text) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt new file mode 100644 index 000000000..64bbd21d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt @@ -0,0 +1,76 @@ +package org.thoughtcrime.securesms.ui + +import android.content.Context +import androidx.annotation.AttrRes +import androidx.appcompat.view.ContextThemeWrapper +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.google.accompanist.themeadapter.appcompat.AppCompatTheme +import com.google.android.material.color.MaterialColors +import network.loki.messenger.R + +val LocalExtraColors = staticCompositionLocalOf { error("No Custom Attribute value provided") } + + +data class ExtraColors( + val settingsBackground: Color, +) + +/** + * Converts current Theme to Compose Theme. + */ +@Composable +fun AppTheme( + content: @Composable () -> Unit +) { + val extraColors = LocalContext.current.run { + ExtraColors( + settingsBackground = getColorFromTheme(R.attr.colorSettingsBackground), + ) + } + + CompositionLocalProvider(LocalExtraColors provides extraColors) { + AppCompatTheme { + content() + } + } +} + +fun Context.getColorFromTheme(@AttrRes attr: Int, defaultValue: Int = 0x0): Color = + MaterialColors.getColor(this, attr, defaultValue).let(::Color) + +/** + * Set the theme and a background for Compose Previews. + */ +@Composable +fun PreviewTheme( + themeResId: Int, + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalContext provides ContextThemeWrapper(LocalContext.current, themeResId) + ) { + AppTheme { + Box(modifier = Modifier.background(color = MaterialTheme.colors.background)) { + content() + } + } + } +} + +class ThemeResPreviewParameterProvider : PreviewParameterProvider { + override val values = sequenceOf( + R.style.Classic_Dark, + R.style.Classic_Light, + R.style.Ocean_Dark, + R.style.Ocean_Light, + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt index d5b361ecd..5ff823a15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt @@ -7,10 +7,10 @@ import android.view.View import androidx.annotation.StyleRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.DialogFragment import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.BaseActionBarActivity -import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog fun BaseActionBarActivity.setUpActionBarSessionLogo(hideBackButton: Boolean = false) { val actionbar = supportActionBar!! @@ -66,7 +66,7 @@ interface ActivityDispatcher { fun get(context: Context) = context.getSystemService(SERVICE) as? ActivityDispatcher } fun dispatchIntent(body: (Context)->Intent?) - fun showDialog(baseDialog: BaseDialog, tag: String? = null) + fun showDialog(dialogFragment: DialogFragment, tag: String? = null) } fun TextSecurePreferences.themeState(): ThemeState { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt index 56c0a55dd..0ba63fc54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt @@ -1,12 +1,10 @@ package org.thoughtcrime.securesms.util import android.app.Notification -import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK -import android.os.Build import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.app.NotificationCompat @@ -32,15 +30,7 @@ class CallNotificationBuilder { @JvmStatic fun areNotificationsEnabled(context: Context): Boolean { val notificationManager = NotificationManagerCompat.from(context) - return when { - !notificationManager.areNotificationsEnabled() -> false - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { - notificationManager.notificationChannels.firstOrNull { channel -> - channel.importance == NotificationManager.IMPORTANCE_NONE - } == null - } - else -> true - } + return notificationManager.areNotificationsEnabled() } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt index fd462417d..297014d86 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt @@ -1,18 +1,66 @@ package org.thoughtcrime.securesms.util import android.content.Context +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 network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.Contact +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 nl.komponents.kovenant.Promise +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.jobs.ConfigurationSyncJob +import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.ConfigurationMessage 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.GroupUtil import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.WindowDebouncer +import org.session.libsignal.crypto.ecc.DjbECPublicKey +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import java.util.Timer object ConfigurationMessageUtilities { + private val debouncer = WindowDebouncer(3000, Timer()) + + private fun scheduleConfigSync(userPublicKey: String) { + debouncer.publish { + // don't schedule job if we already have one + val storage = MessagingModuleConfiguration.shared.storage + val ourDestination = Destination.Contact(userPublicKey) + val currentStorageJob = storage.getConfigSyncJob(ourDestination) + if (currentStorageJob != null) { + (currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true) + return@publish + } + val newConfigSync = ConfigurationSyncJob(ourDestination) + JobQueue.shared.add(newConfigSync) + } + } + @JvmStatic fun syncConfigurationIfNeeded(context: Context) { + // add if check here to schedule new config job process and return early val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return + val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context) + val currentTime = SnodeAPI.nowWithOffset + if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { + scheduleConfigSync(userPublicKey) + return + } val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context) val now = System.currentTimeMillis() if (now - lastSyncTime < 7 * 24 * 60 * 60 * 1000) return @@ -35,7 +83,16 @@ object ConfigurationMessageUtilities { } fun forceSyncConfigurationNowIfNeeded(context: Context): Promise { - val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofSuccess(Unit) + // add if check here to schedule new config job process and return early + val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofFail(NullPointerException("User Public Key is null")) + val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context) + val currentTime = SnodeAPI.nowWithOffset + if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { + // schedule job if none exist + // don't schedule job if we already have one + scheduleConfigSync(userPublicKey) + return Promise.ofSuccess(Unit) + } val contacts = ContactUtilities.getAllContacts(context).filter { recipient -> !recipient.isGroupRecipient && !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty() }.map { recipient -> @@ -50,9 +107,179 @@ object ConfigurationMessageUtilities { ) } val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return Promise.ofSuccess(Unit) - val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey))) + val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey)), isSyncMessage = true) TextSecurePreferences.setLastConfigurationSyncTime(context, System.currentTimeMillis()) return promise } + private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes + + fun generateUserProfileConfigDump(): ByteArray? { + val storage = MessagingModuleConfiguration.shared.storage + val ownPublicKey = storage.getUserPublicKey() ?: return null + val config = ConfigurationMessage.getCurrent(listOf()) ?: return null + val secretKey = maybeUserSecretKey() ?: return null + val profile = UserProfile.newInstance(secretKey) + profile.setName(config.displayName) + val picUrl = config.profilePicture + val picKey = config.profileKey + if (!picUrl.isNullOrEmpty() && picKey.isNotEmpty()) { + profile.setPic(UserPic(picUrl, picKey)) + } + val ownThreadId = storage.getThreadId(Address.fromSerialized(ownPublicKey)) + profile.setNtsPriority( + if (ownThreadId != null) + if (storage.isPinned(ownThreadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE + else ConfigBase.PRIORITY_HIDDEN + ) + val dump = profile.dump() + profile.free() + return dump + } + + fun generateContactConfigDump(): ByteArray? { + val secretKey = maybeUserSecretKey() ?: return null + val storage = MessagingModuleConfiguration.shared.storage + val localUserKey = storage.getUserPublicKey() ?: return null + val contactsWithSettings = storage.getAllContacts().filter { recipient -> + recipient.sessionID != localUserKey && recipient.sessionID.startsWith(IdPrefix.STANDARD.value) + && storage.getThreadId(recipient.sessionID) != null + }.map { contact -> + val address = Address.fromSerialized(contact.sessionID) + val thread = storage.getThreadId(address) + val isPinned = if (thread != null) { + storage.isPinned(thread) + } else false + + Triple(contact, storage.getRecipientSettings(address)!!, isPinned) + } + val contactConfig = Contacts.newInstance(secretKey) + for ((contact, settings, isPinned) in contactsWithSettings) { + val url = contact.profilePictureURL + val key = contact.profilePictureEncryptionKey + val userPic = if (url.isNullOrEmpty() || key?.isNotEmpty() != true) { + null + } else { + UserPic(url, key) + } + + val contactInfo = Contact( + id = contact.sessionID, + name = contact.name.orEmpty(), + nickname = contact.nickname.orEmpty(), + blocked = settings.isBlocked, + approved = settings.isApproved, + approvedMe = settings.hasApprovedMe(), + profilePicture = userPic ?: UserPic.DEFAULT, + priority = if (isPinned) 1 else 0, + expiryMode = if (settings.expireMessages == 0) ExpiryMode.NONE else ExpiryMode.AfterRead(settings.expireMessages.toLong()) + ) + contactConfig.set(contactInfo) + } + val dump = contactConfig.dump() + contactConfig.free() + if (dump.isEmpty()) return null + return dump + } + + fun generateConversationVolatileDump(context: Context): ByteArray? { + val secretKey = maybeUserSecretKey() ?: return null + val storage = MessagingModuleConfiguration.shared.storage + val convoConfig = ConversationVolatileConfig.newInstance(secretKey) + val threadDb = DatabaseComponent.get(context).threadDatabase() + threadDb.approvedConversationList.use { cursor -> + val reader = threadDb.readerFor(cursor) + var current = reader.next + while (current != null) { + val recipient = current.recipient + val contact = when { + recipient.isOpenGroupRecipient -> { + val openGroup = storage.getOpenGroup(current.threadId) ?: continue + val (base, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: continue + convoConfig.getOrConstructCommunity(base, room, pubKey) + } + recipient.isClosedGroupRecipient -> { + val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) + convoConfig.getOrConstructLegacyGroup(groupPublicKey) + } + recipient.isContactRecipient -> { + if (recipient.isLocalNumber) null // this is handled by the user profile NTS data + else if (recipient.isOpenGroupInboxRecipient) null // specifically exclude + else if (!recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) null + else convoConfig.getOrConstructOneToOne(recipient.address.serialize()) + } + else -> null + } + if (contact == null) { + current = reader.next + continue + } + contact.lastRead = current.lastSeen + contact.unread = false + convoConfig.set(contact) + current = reader.next + } + } + + val dump = convoConfig.dump() + convoConfig.free() + if (dump.isEmpty()) return null + return dump + } + + fun generateUserGroupDump(context: Context): ByteArray? { + val secretKey = maybeUserSecretKey() ?: return null + val storage = MessagingModuleConfiguration.shared.storage + val groupConfig = UserGroupsConfig.newInstance(secretKey) + val allOpenGroups = storage.getAllOpenGroups().values.mapNotNull { openGroup -> + val (baseUrl, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return@mapNotNull null + val pubKeyHex = Hex.toStringCondensed(pubKey) + val baseInfo = BaseCommunityInfo(baseUrl, room, pubKeyHex) + val threadId = storage.getThreadId(openGroup) ?: return@mapNotNull null + val isPinned = storage.isPinned(threadId) + GroupInfo.CommunityGroupInfo(baseInfo, if (isPinned) 1 else 0) + } + + val allLgc = storage.getAllGroups(includeInactive = false).filter { + it.isClosedGroup && it.isActive && it.members.size > 1 + }.mapNotNull { group -> + val groupAddress = Address.fromSerialized(group.encodedId) + val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.serialize()).toHexString() + val recipient = storage.getRecipientSettings(groupAddress) ?: return@mapNotNull null + val encryptionKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return@mapNotNull null + val threadId = storage.getThreadId(group.encodedId) + val isPinned = threadId?.let { storage.isPinned(threadId) } ?: false + val admins = group.admins.map { it.serialize() to true }.toMap() + val members = group.members.filterNot { it.serialize() !in admins.keys }.map { it.serialize() to false }.toMap() + GroupInfo.LegacyGroupInfo( + sessionId = groupPublicKey, + name = group.title, + members = admins + members, + priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, + encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte + encSecKey = encryptionKeyPair.privateKey.serialize(), + disappearingTimer = recipient.expireMessages.toLong(), + joinedAt = (group.formationTimestamp / 1000L) + ) + } + (allOpenGroups + allLgc).forEach { groupInfo -> + groupConfig.set(groupInfo) + } + val dump = groupConfig.dump() + groupConfig.free() + if (dump.isEmpty()) return null + return dump + } + + @JvmField + val DELETE_INACTIVE_GROUPS: String = """ + DELETE FROM ${GroupDatabase.TABLE_NAME} WHERE ${GroupDatabase.GROUP_ID} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%'); + DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ADDRESS} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%'); + """.trimIndent() + + @JvmField + val DELETE_INACTIVE_ONE_TO_ONES: String = """ + DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_INBOX_PREFIX}%'; + """.trimIndent() + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java index 874440f5d..66c838cc1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java @@ -67,7 +67,8 @@ public class DateUtils extends android.text.format.DateUtils { } public static String getDisplayFormattedTimeSpanString(final Context c, final Locale locale, final long timestamp) { - if (isWithin(timestamp, 1, TimeUnit.MINUTES)) { + // If the timestamp is invalid (ie. 0) then assume we're waiting on data and just use the 'Now' copy + if (timestamp == 0 || isWithin(timestamp, 1, TimeUnit.MINUTES)) { return c.getString(R.string.DateUtils_just_now); } else if (isToday(timestamp)) { return getFormattedDateTime(timestamp, getHourFormat(c), locale); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt index 08b81e5cb..c7d53c1fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt @@ -7,6 +7,7 @@ import android.graphics.Canvas import android.graphics.Paint import android.util.AttributeSet import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.annotation.ColorInt @@ -55,16 +56,21 @@ object GlowViewUtilities { animation.start() } - fun animateShadowColorChange(view: GlowView, @ColorInt startColor: Int, @ColorInt endColor: Int) { + fun animateShadowColorChange( + view: GlowView, + @ColorInt startColor: Int, + @ColorInt endColor: Int, + duration: Long = 250 + ) { val animation = ValueAnimator.ofObject(ArgbEvaluator(), startColor, endColor) - animation.duration = 250 + animation.duration = duration + animation.interpolator = AccelerateDecelerateInterpolator() animation.addUpdateListener { animator -> val color = animator.animatedValue as Int view.sessionShadowColor = color } animation.start() } - } class PNModeView : LinearLayout, GlowView { @@ -223,3 +229,59 @@ class InputBarButtonImageViewContainer : RelativeLayout, GlowView { } // endregion } + +class MessageBubbleView : androidx.constraintlayout.widget.ConstraintLayout, GlowView { + @ColorInt override var mainColor: Int = 0 + set(newValue) { field = newValue; paint.color = newValue } + @ColorInt override var sessionShadowColor: Int = 0 + set(newValue) { + field = newValue + shadowPaint.setShadowLayer(toPx(10, resources).toFloat(), 0.0f, 0.0f, newValue) + + if (numShadowRenders == 0) { + numShadowRenders = 1 + } + + invalidate() + } + var cornerRadius: Float = 0f + var numShadowRenders: Int = 0 + + private val paint: Paint by lazy { + val result = Paint() + result.style = Paint.Style.FILL + result.isAntiAlias = true + result + } + + private val shadowPaint: Paint by lazy { + val result = Paint() + result.style = Paint.Style.FILL + result.isAntiAlias = true + result + } + + // region Lifecycle + constructor(context: Context) : super(context) { } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { } + + init { + setWillNotDraw(false) + } + // endregion + + // region Updating + override fun onDraw(c: Canvas) { + val w = width.toFloat() + val h = height.toFloat() + + (0 until numShadowRenders).forEach { + c.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, shadowPaint) + } + + c.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, paint) + super.onDraw(c) + } + // endregion +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt index 10d507a53..06fda2930 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt @@ -7,8 +7,6 @@ import org.session.libsession.messaging.messages.signal.IncomingTextMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi -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.GroupUtil import org.session.libsession.utilities.recipients.Recipient @@ -21,7 +19,6 @@ import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.GroupManager import java.security.SecureRandom -import java.util.* import kotlin.random.asKotlinRandom object MockDataGenerator { @@ -139,7 +136,6 @@ object MockDataGenerator { false ), (timestampNow - (index * 5000)), - false, false ) } @@ -235,8 +231,9 @@ object MockDataGenerator { // Add the group to the user's set of public keys to poll for and store the key pair val encryptionKeyPair = Curve.generateKeyPair() - storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey) + storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey, System.currentTimeMillis()) storage.setExpirationTimer(groupId, 0) + storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair) // Add the group created message if (userSessionId == adminUserId) { @@ -269,7 +266,6 @@ object MockDataGenerator { false ), (timestampNow - (index * 5000)), - false, false ) } @@ -395,7 +391,6 @@ object MockDataGenerator { false ), (timestampNow - (index * 5000)), - false, false ) } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt index 59658f12a..8b219849a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.util import android.content.ContentResolver import android.content.ContentValues import android.content.Context -import android.content.DialogInterface.OnClickListener import android.media.MediaScannerConnection import android.net.Uri import android.os.Build @@ -12,12 +11,12 @@ import android.provider.MediaStore import android.text.TextUtils import android.webkit.MimeTypeMap import android.widget.Toast -import androidx.appcompat.app.AlertDialog import network.loki.messenger.R import org.session.libsession.utilities.task.ProgressDialogAsyncTask import org.session.libsignal.utilities.ExternalStorageUtil import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.showSessionDialog import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -30,7 +29,12 @@ import java.util.concurrent.TimeUnit * Saves attachment files to an external storage using [MediaStore] API. * Requires [android.Manifest.permission.WRITE_EXTERNAL_STORAGE] on API 28 and below. */ -class SaveAttachmentTask : ProgressDialogAsyncTask> { +class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int = 1) : + ProgressDialogAsyncTask>( + context, + context.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments, count, count), + context.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, count, count) + ) { companion object { @JvmStatic @@ -41,30 +45,25 @@ class SaveAttachmentTask : ProgressDialogAsyncTask Unit = {}) { + context.showSessionDialog { + title(R.string.ConversationFragment_save_to_sd_card) + iconAttribute(R.attr.dialog_alert_icon) + text(context.resources.getQuantityString( R.plurals.ConversationFragment_saving_n_media_to_storage_warning, count, count)) - builder.setPositiveButton(R.string.yes, onAcceptListener) - builder.setNegativeButton(R.string.no, null) - builder.show() + button(R.string.yes) { onAcceptListener() } + button(R.string.no) + } } } private val contextReference: WeakReference - private val attachmentCount: Int + private val attachmentCount: Int = count - @JvmOverloads - constructor(context: Context, count: Int = 1): super(context, - context.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments, count, count), - context.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, count, count)) { + init { this.contextReference = WeakReference(context) - this.attachmentCount = count } override fun doInBackground(vararg attachments: Attachment?): Pair { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt index 05b6fe86f..c10e1b635 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt @@ -49,11 +49,11 @@ object SessionMetaProtocol { @JvmStatic fun shouldSendReadReceipt(recipient: Recipient): Boolean { - return !recipient.isGroupRecipient && recipient.isApproved + return !recipient.isGroupRecipient && recipient.isApproved && !recipient.isBlocked } @JvmStatic fun shouldSendTypingIndicator(recipient: Recipient): Boolean { - return !recipient.isGroupRecipient && recipient.isApproved + return !recipient.isGroupRecipient && recipient.isApproved && !recipient.isBlocked } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt new file mode 100644 index 000000000..b15d82a33 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.util + +import network.loki.messenger.libsession_util.ConversationVolatileConfig +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.utilities.GroupUtil +import org.session.libsignal.utilities.IdPrefix +import org.thoughtcrime.securesms.database.model.ThreadRecord + +fun ConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Boolean { + val recipient = thread.recipient + if (recipient.isContactRecipient + && recipient.isOpenGroupInboxRecipient + && recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) { + return getOneToOne(recipient.address.serialize())?.unread == true + } else if (recipient.isClosedGroupRecipient) { + return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true + } else if (recipient.isOpenGroupRecipient) { + val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false + return getCommunity(openGroup.server, openGroup.room)?.unread == true + } + return false +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt index 834547349..dfd4ffe41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -58,7 +58,7 @@ fun View.fadeIn(duration: Long = 150) { fun View.fadeOut(duration: Long = 150) { animate().setDuration(duration).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) visibility = View.GONE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index d96de5eed..894de9de6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -303,7 +303,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va sdpMLineIndexes = sdpMLineIndexes, sdpMids = sdpMids, currentCallId - ), currentRecipient.address) + ), currentRecipient.address, isSyncMessage = currentRecipient.isLocalNumber) } } } @@ -437,7 +437,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va pendingIncomingIceUpdates.clear() val answerMessage = CallMessage.answer(answer.description, callId) Log.i("Loki", "Posting new answer") - MessageSender.sendNonDurably(answerMessage, recipient.address) + MessageSender.sendNonDurably(answerMessage, recipient.address, isSyncMessage = recipient.isLocalNumber) } else { Promise.ofFail(Exception("Couldn't reconnect from current state")) } @@ -481,11 +481,11 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va connection.setLocalDescription(answer) val answerMessage = CallMessage.answer(answer.description, callId) val userAddress = storage.getUserPublicKey() ?: return Promise.ofFail(NullPointerException("No user public key")) - MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress)) + MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress), isSyncMessage = true) val sendAnswerMessage = MessageSender.sendNonDurably(CallMessage.answer( answer.description, callId - ), recipient.address) + ), recipient.address, isSyncMessage = recipient.isLocalNumber) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_INCOMING, false) @@ -535,13 +535,13 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va Log.d("Loki", "Sending pre-offer") return MessageSender.sendNonDurably(CallMessage.preOffer( callId - ), recipient.address).bind { + ), recipient.address, isSyncMessage = recipient.isLocalNumber).bind { Log.d("Loki", "Sent pre-offer") Log.d("Loki", "Sending offer") MessageSender.sendNonDurably(CallMessage.offer( offer.description, callId - ), recipient.address).success { + ), recipient.address, isSyncMessage = recipient.isLocalNumber).success { Log.d("Loki", "Sent offer") }.fail { Log.e("Loki", "Failed to send offer", it) @@ -555,8 +555,8 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va val recipient = recipient ?: return val userAddress = storage.getUserPublicKey() ?: return stateProcessor.processEvent(Event.DeclineCall) { - MessageSender.sendNonDurably(CallMessage.endCall(callId), Address.fromSerialized(userAddress)) - MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address) + MessageSender.sendNonDurably(CallMessage.endCall(callId), Address.fromSerialized(userAddress), isSyncMessage = true) + MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address, isSyncMessage = recipient.isLocalNumber) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_MISSED) } } @@ -575,7 +575,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va val buffer = DataChannel.Buffer(ByteBuffer.wrap(HANGUP_JSON.toString().encodeToByteArray()), false) channel.send(buffer) } - MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address) + MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address, isSyncMessage = recipient.isLocalNumber) } } @@ -726,7 +726,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va }) connection.setLocalDescription(offer) - MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address) + MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address, isSyncMessage = recipient.isLocalNumber) } } diff --git a/app/src/main/res/color/prominent_button_color.xml b/app/src/main/res/color/prominent_button_color.xml index 8f2e692fd..39985565d 100644 --- a/app/src/main/res/color/prominent_button_color.xml +++ b/app/src/main/res/color/prominent_button_color.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_expand.xml b/app/src/main/res/drawable/ic_expand.xml new file mode 100644 index 000000000..3b2b816a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_message_details__refresh.xml b/app/src/main/res/drawable/ic_message_details__refresh.xml new file mode 100644 index 000000000..2aabe6fbe --- /dev/null +++ b/app/src/main/res/drawable/ic_message_details__refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_message_details__reply.xml b/app/src/main/res/drawable/ic_message_details__reply.xml new file mode 100644 index 000000000..c9e1591a5 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_details__reply.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_message_details__trash.xml b/app/src/main/res/drawable/ic_message_details__trash.xml new file mode 100644 index 000000000..85d421695 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_details__trash.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_next.xml b/app/src/main/res/drawable/ic_next.xml new file mode 100644 index 000000000..1e72d86cb --- /dev/null +++ b/app/src/main/res/drawable/ic_next.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_prev.xml b/app/src/main/res/drawable/ic_prev.xml new file mode 100644 index 000000000..f72026167 --- /dev/null +++ b/app/src/main/res/drawable/ic_prev.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index d56b399fc..5afde1e29 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -216,6 +216,19 @@ + + - diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index a18661f89..bf308612c 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -27,7 +27,7 @@ android:layout_marginLeft="20dp" android:layout_marginRight="20dp"> - + android:layout_height="@dimen/path_status_view_size" + android:layout_alignEnd="@+id/profileButton" + android:layout_alignBottom="@+id/profileButton" /> + android:layout_height="?actionBarSize" + android:layout_marginHorizontal="@dimen/medium_spacing" + android:visibility="gone"> + + android:layout_height="wrap_content" + android:layout_centerVertical="true" /> + + + + + + android:clipChildren="false" + android:focusable="false"> + tools:listitem="@layout/view_global_search_result" /> - - - -