diff --git a/.drone.jsonnet b/.drone.jsonnet new file mode 100644 index 0000000000..dc81115ce9 --- /dev/null +++ b/.drone.jsonnet @@ -0,0 +1,88 @@ +local docker_base = 'registry.oxen.rocks/lokinet-ci-'; + +// Log a bunch of version information to make it easier for debugging +local version_info = { + name: 'Version Information', + image: docker_base + 'android', + commands: [ + 'cmake --version', + 'apt --installed list' + ] +}; + + +// Intentionally doing a depth of 2 as libSession-util has it's own submodules (and libLokinet likely will as well) +local clone_submodules = { + name: 'Clone Submodules', + image: 'drone/git', + commands: ['git fetch --tags', 'git submodule update --init --recursive --depth=2 --jobs=4'] +}; + +// cmake options for static deps mirror +local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https://oxen.rocks/deps ' else ''); + +[ + // Unit tests (PRs only) + { + kind: 'pipeline', + type: 'docker', + name: 'Unit Tests', + platform: { arch: 'amd64' }, + trigger: { event: { exclude: [ 'push' ] } }, + steps: [ + version_info, + clone_submodules, + { + name: 'Run Unit Tests', + image: docker_base + 'android', + pull: 'always', + environment: { ANDROID_HOME: '/usr/lib/android-sdk' }, + commands: [ + 'apt-get install -y ninja-build', + './gradlew testPlayDebugUnitTestCoverageReport' + ], + } + ], + }, + // Validate build artifact was created by the direct branch push (PRs only) + { + kind: 'pipeline', + type: 'docker', + name: 'Check Build Artifact Existence', + platform: { arch: 'amd64' }, + trigger: { event: { exclude: [ 'push' ] } }, + steps: [ + { + name: 'Poll for build artifact existence', + image: docker_base + 'android', + pull: 'always', + commands: [ + './scripts/drone-upload-exists.sh' + ] + } + ] + }, + // Debug APK build (non-PRs only) + { + kind: 'pipeline', + type: 'docker', + name: 'Debug APK Build', + platform: { arch: 'amd64' }, + trigger: { event: { exclude: [ 'pull_request' ] } }, + steps: [ + version_info, + clone_submodules, + { + name: 'Build and upload', + image: docker_base + 'android', + pull: 'always', + environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' }, + commands: [ + 'apt-get install -y ninja-build', + './gradlew assemblePlayDebug', + './scripts/drone-static-upload.sh' + ], + } + ], + } +] \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 7c17252c2d..0000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,45 +0,0 @@ - - -- [ ] I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-android/blob/master/CODE_OF_CONDUCT.md). -- [ ] I have searched open and closed issues for duplicates -- [ ] I am submitting a bug report for existing functionality that does not work as intended -- [ ] This isn't a feature request or a discussion topic - ----------------------------------------- - -### Bug description -Describe here the issue that you are experiencing. - -### Steps to reproduce -- using hyphens as bullet points -- list the steps -- that reproduce the bug - -**Actual result:** - -Describe here what happens after you run the steps above (i.e. the buggy behaviour) - -**Expected result:** - -Describe here what should happen after you run the steps above (i.e. what would be the correct behaviour) - -### Screenshots - - -### Device info - - -**Device:** Manufacturer Model XVI - -**Android version:** 0.0.0 - -**Session version:** 0.0.0 - -### Link to debug log diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 74bbafd0f6..0000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug -assignees: '' - ---- - -**Code of conduct** - -- [ ] I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-android/blob/master/CODE_OF_CONDUCT.md). - -**Describe the bug** - -A clear and concise description of what the bug is. - -**To reproduce** - -Steps to reproduce the behavior: - -**Screenshots or logs** - -If applicable, add screenshots or logs to help explain your problem. - -**Smartphone (please complete the following information):** - - - Device: [e.g. Samsung Galaxy S8] - - OS: [e.g. Android Pie] - - Version of Session or latest commit hash - -**Additional context** - -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..883f792482 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,74 @@ +name: 🐞 Bug Report +description: Create a report to help us improve +title: "[BUG] " +labels: [bug] +body: +- type: checkboxes + attributes: + label: Code of conduct + description: I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-android/blob/master/CODE_OF_CONDUCT.md). + options: + - label: I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-android/blob/master/CODE_OF_CONDUCT.md) + required: true + +- type: checkboxes + attributes: + label: Self-training on how to write a bug report + description: High quality bug reports can help the team save time and improve the chance of getting your issue fixed. Please read [how to write a bug report](https://www.browserstack.com/guide/how-to-write-a-bug-report) before submitting your issue. + options: + - label: I have learned [how to write a bug report](https://www.browserstack.com/guide/how-to-write-a-bug-report) + required: true + +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered. + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: Current Behavior + description: A concise description of what you're experiencing. + validations: + required: false +- type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: false +- type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. In this environment... + 2. With this config... + 3. Run '...' + 4. See error... + validations: + required: false +- type: input + attributes: + label: Android Version + description: What version of Android are you running? + placeholder: ex. Android 11 + validations: + required: false +- type: input + attributes: + label: Session Version + description: What version of Session are you running? (This can be found at the bottom of the app settings) + placeholder: ex. 1.17.0 (3425) + validations: + required: false +- type: textarea + attributes: + label: Anything else? + description: | + Add any other context about the problem here. + + Tip: You can attach screenshots or log files to help explain your problem by clicking this area to highlight it and then dragging files in. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..3c9712e52a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,26 @@ +name: 🚀 Feature request +description: Suggest an idea for Session +title: '[Feature] <title>' +labels: [feature-request] +body: +- type: checkboxes + attributes: + label: Is there an existing request for feature? + description: Please search to see if an issue already exists for the feature you are requesting. + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: What feature would you like? + description: | + A clear and concise description of the feature you would like added to Session + validations: + required: true +- type: textarea + attributes: + label: Anything else? + description: | + Add any other context or screenshots about the feature request here + validations: + required: false diff --git a/.gitignore b/.gitignore index 023fc81010..be928b3933 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ ffpr *.sh pkcs11.password app/play -app/huawei \ No newline at end of file +app/huawei + +!/scripts/drone-static-upload.sh +!/scripts/drone-upload-exists.sh \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 2bf496cd49..1e63207bf9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ configurations.all { exclude module: "commons-logging" } -def canonicalVersionCode = 355 -def canonicalVersionName = "1.17.1" +def canonicalVersionCode = 369 +def canonicalVersionName = "1.18.1" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, @@ -122,13 +122,16 @@ android { minifyEnabled false } debug { + isDefault true minifyEnabled false + enableUnitTestCoverage true } } flavorDimensions "distribution" productFlavors { play { + isDefault true dimension "distribution" apply plugin: 'com.google.gms.google-services' ext.websiteUpdateUrl = "null" @@ -199,6 +202,27 @@ android { } } } + + task testPlayDebugUnitTestCoverageReport(type: JacocoReport, dependsOn: "testPlayDebugUnitTest") { + reports { + xml.enabled = true + } + + // Add files that should not be listed in the report (e.g. generated Files from dagger) + def fileFilter = [] + def mainSrc = "$projectDir/src/main/java" + def kotlinDebugTree = fileTree(dir: "${buildDir}/tmp/kotlin-classes/playDebug", excludes: fileFilter) + + // Compiled Kotlin class files are written into build-variant-specific subdirectories of 'build/tmp/kotlin-classes'. + classDirectories.from = files([kotlinDebugTree]) + + // To produce an accurate report, the bytecode is mapped back to the original source code. + sourceDirectories.from = files([mainSrc]) + + // Execution data generated when running the tests against classes instrumented by the JaCoCo agent. + // This is enabled with 'enableUnitTestCoverage' in the 'debug' build type. + executionData.from = "${project.buildDir}/outputs/unit_test_code_coverage/playDebugUnitTest/testPlayDebugUnitTest.exec" + } } dependencies { @@ -282,7 +306,7 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion" implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version" implementation project(":liblazysodium") - implementation "net.java.dev.jna:jna:5.8.0@aar" + implementation "net.java.dev.jna:jna:5.12.1@aar" implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" @@ -297,9 +321,9 @@ dependencies { implementation "com.opencsv:opencsv:4.6" testImplementation "junit:junit:$junitVersion" testImplementation 'org.assertj:assertj-core:3.11.1' - testImplementation "org.mockito:mockito-inline:4.10.0" + testImplementation "org.mockito:mockito-inline:4.11.0" testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" - androidTestImplementation "org.mockito:mockito-android:4.10.0" + androidTestImplementation "org.mockito:mockito-android:4.11.0" androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" testImplementation "androidx.test:core:$testCoreVersion" testImplementation "androidx.arch.core:core-testing:2.2.0" @@ -319,6 +343,7 @@ dependencies { // Assertions androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:truth:1.5.0' + testImplementation 'com.google.truth:truth:1.1.3' androidTestImplementation 'com.google.truth:truth:1.1.3' // Espresso dependencies @@ -334,15 +359,15 @@ dependencies { 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 'com.github.bumptech.glide:compose:1.0.0-alpha.5' + implementation 'androidx.compose.ui:ui:1.5.2' + implementation 'androidx.compose.ui:ui-tooling:1.5.2' + implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha" + implementation "com.google.accompanist:accompanist-pager-indicators:0.33.1-alpha" + implementation "androidx.compose.runtime:runtime-livedata:1.5.2" - implementation 'androidx.compose.foundation:foundation-layout:1.5.0-alpha02' - implementation 'androidx.compose.material:material:1.5.0-alpha02' + implementation 'androidx.compose.foundation:foundation-layout:1.5.2' + implementation 'androidx.compose.material:material:1.5.2' } static def getLastCommitTimestamp() { diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt index eabe06f7d9..a20a3a2a67 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -158,6 +158,7 @@ class HomeActivityTests { val dialogPromptText = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.dialog_open_url_explanation, amazonPuny) + onView(isRoot()).perform(waitFor(1000)) // no other way for this to work apparently onView(withText(dialogPromptText)).check(matches(isDisplayed())) } diff --git a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt index 59cb8ede08..157085135e 100644 --- a/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt @@ -7,16 +7,25 @@ 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.ConversationVolatileConfig import network.loki.messenger.libsession_util.util.Contact +import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.ExpiryMode +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.instanceOf +import org.hamcrest.MatcherAssert.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.argThat +import org.mockito.kotlin.argWhere 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.snode.SnodeAPI +import org.session.libsession.utilities.Address import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.hexEncodedPublicKey @@ -50,13 +59,22 @@ class LibSessionTests { private fun buildContactMessage(contactList: List<Contact>): ByteArray { val (key,_) = maybeGetUserInfo()!! - val contacts = Contacts.Companion.newInstance(key) + val contacts = Contacts.newInstance(key) contactList.forEach { contact -> contacts.set(contact) } return contacts.push().config } + private fun buildVolatileMessage(conversations: List<Conversation>): ByteArray { + val (key, _) = maybeGetUserInfo()!! + val volatile = ConversationVolatileConfig.newInstance(key) + conversations.forEach { conversation -> + volatile.set(conversation) + } + return volatile.push().config + } + private fun fakePollNewConfig(configBase: ConfigBase, toMerge: ByteArray) { configBase.merge(nextFakeHash to toMerge) MessagingModuleConfiguration.shared.configFactory.persist(configBase, System.currentTimeMillis()) @@ -95,8 +113,83 @@ class LibSessionTests { fakePollNewConfig(contacts, newContactMerge) verify(storageSpy).addLibSessionContacts(argThat { first().let { it.id == newContactId && it.approved } && size == 1 - }) + }, any()) verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true)) } + @Test + fun test_expected_configs() { + val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + val storageSpy = spy(app.storage) + app.storage = storageSpy + + val randomRecipient = randomSessionId() + val newContact = Contact( + id = randomRecipient, + approved = true, + expiryMode = ExpiryMode.AfterSend(1000) + ) + val newConvo = Conversation.OneToOne( + randomRecipient, + SnodeAPI.nowWithOffset, + false + ) + val volatiles = MessagingModuleConfiguration.shared.configFactory.convoVolatile!! + val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!! + val newContactMerge = buildContactMessage(listOf(newContact)) + val newVolatileMerge = buildVolatileMessage(listOf(newConvo)) + fakePollNewConfig(contacts, newContactMerge) + fakePollNewConfig(volatiles, newVolatileMerge) + verify(storageSpy).setExpirationConfiguration(argWhere { config -> + config.expiryMode is ExpiryMode.AfterSend + && config.expiryMode.expirySeconds == 1000L + }) + val threadId = storageSpy.getThreadId(Address.fromSerialized(randomRecipient))!! + val newExpiry = storageSpy.getExpirationConfiguration(threadId)!! + assertThat(newExpiry.expiryMode, instanceOf(ExpiryMode.AfterSend::class.java)) + assertThat(newExpiry.expiryMode.expirySeconds, equalTo(1000)) + assertThat(newExpiry.expiryMode.expiryMillis, equalTo(1000000)) + } + + @Test + fun test_overwrite_config() { + val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + val storageSpy = spy(app.storage) + app.storage = storageSpy + + // Initial state + val randomRecipient = randomSessionId() + val currentContact = Contact( + id = randomRecipient, + approved = true, + expiryMode = ExpiryMode.NONE + ) + val newConvo = Conversation.OneToOne( + randomRecipient, + SnodeAPI.nowWithOffset, + false + ) + val volatiles = MessagingModuleConfiguration.shared.configFactory.convoVolatile!! + val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!! + val newContactMerge = buildContactMessage(listOf(currentContact)) + val newVolatileMerge = buildVolatileMessage(listOf(newConvo)) + fakePollNewConfig(contacts, newContactMerge) + fakePollNewConfig(volatiles, newVolatileMerge) + verify(storageSpy).setExpirationConfiguration(argWhere { config -> + config.expiryMode == ExpiryMode.NONE + }) + val threadId = storageSpy.getThreadId(Address.fromSerialized(randomRecipient))!! + val currentExpiryConfig = storageSpy.getExpirationConfiguration(threadId)!! + assertThat(currentExpiryConfig.expiryMode, equalTo(ExpiryMode.NONE)) + assertThat(currentExpiryConfig.expiryMode.expirySeconds, equalTo(0)) + assertThat(currentExpiryConfig.expiryMode.expiryMillis, equalTo(0)) + // Set new state and overwrite + val updatedContact = currentContact.copy(expiryMode = ExpiryMode.AfterSend(1000)) + val updateContactMerge = buildContactMessage(listOf(updatedContact)) + fakePollNewConfig(contacts, updateContactMerge) + val updatedExpiryConfig = storageSpy.getExpirationConfiguration(threadId)!! + assertThat(updatedExpiryConfig.expiryMode, instanceOf(ExpiryMode.AfterSend::class.java)) + assertThat(updatedExpiryConfig.expiryMode.expirySeconds, equalTo(1000)) + } + } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cc63ab6a87..c89fca8a70 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,6 +34,8 @@ <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.USE_FINGERPRINT" /> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/> <uses-permission android:name="network.loki.messenger.ACCESS_SESSION_SECRETS" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> @@ -176,6 +178,9 @@ android:screenOrientation="portrait" /> <activity android:name="org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity" android:screenOrientation="portrait"/> + <activity android:name="org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity" + android:screenOrientation="portrait" + android:theme="@style/Theme.Session.DayNight.NoActionBar" /> <activity android:exported="true" @@ -235,10 +240,6 @@ android:screenOrientation="portrait" android:theme="@style/Theme.Session.DayNight"> </activity> - <activity - android:name="org.thoughtcrime.securesms.longmessage.LongMessageActivity" - android:screenOrientation="portrait" - android:theme="@style/Theme.Session.DayNight" /> <activity android:name="org.thoughtcrime.securesms.DatabaseUpgradeActivity" android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" @@ -311,11 +312,15 @@ android:value="org.thoughtcrime.securesms.home.HomeActivity" /> </activity> <service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService" + android:foregroundServiceType="microphone" android:exported="false" /> <service android:name="org.thoughtcrime.securesms.service.KeyCachingService" android:enabled="true" - android:exported="false" /> + android:exported="false" android:foregroundServiceType="specialUse"> +<!-- <property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"--> +<!-- android:value="@string/preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint"/>--> + </service> <service android:name="org.thoughtcrime.securesms.service.DirectShareService" android:exported="true" diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 8715042c8e..f95e818237 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -197,12 +197,12 @@ public class ApplicationContext extends Application implements DefaultLifecycleO } @Override - public void notifyUpdates(@NonNull ConfigBase forConfigObject) { + public void notifyUpdates(@NonNull ConfigBase forConfigObject, long messageTimestamp) { // forward to the config factory / storage ig if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) { textSecurePreferences.setConfigurationMessageSynced(true); } - storage.notifyConfigUpdates(forConfigObject); + storage.notifyConfigUpdates(forConfigObject, messageTimestamp); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.kt deleted file mode 100644 index 9a34c1ec4b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.thoughtcrime.securesms - -import android.content.Context -import android.view.LayoutInflater -import android.widget.TextView -import androidx.appcompat.app.AlertDialog -import cn.carbswang.android.numberpickerview.library.NumberPickerView -import network.loki.messenger.R -import org.session.libsession.utilities.ExpirationUtil - -fun Context.showExpirationDialog( - expiration: Int, - onExpirationTime: (Int) -> Unit -): AlertDialog { - val view = LayoutInflater.from(this).inflate(R.layout.expiration_dialog, null) - val numberPickerView = view.findViewById<NumberPickerView>(R.id.expiration_number_picker) - - fun updateText(index: Int) { - view.findViewById<TextView>(R.id.expiration_details).text = when (index) { - 0 -> getString(R.string.ExpirationDialog_your_messages_will_not_expire) - else -> getString( - R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen, - numberPickerView.displayedValues[index] - ) - } - } - - val expirationTimes = resources.getIntArray(R.array.expiration_times) - val expirationDisplayValues = expirationTimes - .map { ExpirationUtil.getExpirationDisplayValue(this, it) } - .toTypedArray() - - val selectedIndex = expirationTimes.run { indexOfFirst { it >= expiration }.coerceIn(indices) } - - numberPickerView.apply { - displayedValues = expirationDisplayValues - minValue = 0 - maxValue = expirationTimes.lastIndex - setOnValueChangedListener { _, _, index -> updateText(index) } - value = selectedIndex - } - - updateText(selectedIndex) - - return showSessionDialog { - title(getString(R.string.ExpirationDialog_disappearing_messages)) - view(view) - okButton { onExpirationTime(numberPickerView.let { expirationTimes[it.value] }) } - cancelButton() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index f19a1fc45e..b74638fec7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -47,7 +47,6 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; import androidx.core.util.Pair; import androidx.lifecycle.ViewModelProvider; import androidx.loader.app.LoaderManager; @@ -534,11 +533,15 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im viewModel.setCursor(this, data.first, leftIsRecent); - int item = restartItem >= 0 ? restartItem : data.second; - mediaPager.setCurrentItem(item); + if (restartItem >= 0 || data.second >= 0) { + int item = restartItem >= 0 ? restartItem : data.second; + mediaPager.setCurrentItem(item); - if (item == 0) { - viewPagerListener.onPageSelected(0); + if (item == 0) { + viewPagerListener.onPageSelected(0); + } + } else { + Log.w(TAG, "one of restartItem "+restartItem+" and data.second "+data.second+" would cause OOB exception"); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java index a791d77a57..bf2aba63f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActionBarActivity.java @@ -9,13 +9,14 @@ import android.os.Bundle; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; +import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.home.HomeActivity; import org.thoughtcrime.securesms.onboarding.LandingActivity; import org.thoughtcrime.securesms.service.KeyCachingService; -import org.session.libsession.utilities.TextSecurePreferences; import java.util.Locale; @@ -168,7 +169,13 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA }; IntentFilter filter = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT); - registerReceiver(clearKeyReceiver, filter, KeyCachingService.KEY_PERMISSION, null); + ContextCompat.registerReceiver( + this, + clearKeyReceiver, filter, + KeyCachingService.KEY_PERMISSION, + null, + ContextCompat.RECEIVER_NOT_EXPORTED + ); } private void removeClearKeyReceiver(Context context) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt index 44c30741ef..a99b40f36f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt @@ -105,7 +105,7 @@ class SessionDialogBuilder(val context: Context) { fun destructiveButton( @StringRes text: Int, - @StringRes contentDescription: Int, + @StringRes contentDescription: Int = text, listener: () -> Unit = {} ) = button( text, diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index b00ed7d2ee..3fd91f7f63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -186,7 +186,7 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) else DatabaseComponent.get(context).mmsDatabase() messagingDatabase.deleteMessage(messageID) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageID, isSms) - DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID) + DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID, mms = !isSms) } override fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) { @@ -195,7 +195,7 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId) DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs) - DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs) + DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs, mms = !isSms) } override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? { @@ -212,15 +212,12 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) return message.id } - override fun getServerHashForMessage(messageID: Long): String? { - val messageDB = DatabaseComponent.get(context).lokiMessageDatabase() - return messageDB.getMessageServerHash(messageID) - } + override fun getServerHashForMessage(messageID: Long, mms: Boolean): String? = + DatabaseComponent.get(context).lokiMessageDatabase().getMessageServerHash(messageID, mms) - override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? { - val attachmentDatabase = DatabaseComponent.get(context).attachmentDatabase() - return attachmentDatabase.getAttachment(AttachmentId(attachmentId, 0)) - } + override fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? = + DatabaseComponent.get(context).attachmentDatabase() + .getAttachment(AttachmentId(attachmentId, 0)) private fun scaleAndStripExif(attachmentDatabase: AttachmentDatabase, constraints: MediaConstraints, attachment: Attachment): Attachment? { return try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java deleted file mode 100644 index 1ac4f8442b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java +++ /dev/null @@ -1,149 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.res.TypedArray; -import android.os.AsyncTask; -import android.util.AttributeSet; -import android.view.View; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.session.libsession.snode.SnodeAPI; -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.service.ExpiringMessageManager; -import org.thoughtcrime.securesms.util.DateUtils; - -import java.util.Locale; - -import network.loki.messenger.R; - -public class ConversationItemFooter extends LinearLayout { - - private TextView dateView; - private ExpirationTimerView timerView; - private ImageView insecureIndicatorView; - private DeliveryStatusView deliveryStatusView; - - public ConversationItemFooter(Context context) { - super(context); - init(null); - } - - public ConversationItemFooter(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - init(attrs); - } - - public ConversationItemFooter(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - init(attrs); - } - - private void init(@Nullable AttributeSet attrs) { - inflate(getContext(), R.layout.conversation_item_footer, this); - - dateView = findViewById(R.id.footer_date); - timerView = findViewById(R.id.footer_expiration_timer); - insecureIndicatorView = findViewById(R.id.footer_insecure_indicator); - deliveryStatusView = findViewById(R.id.footer_delivery_status); - - if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0); - setTextColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_text_color, getResources().getColor(R.color.core_white))); - setIconColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_icon_color, getResources().getColor(R.color.core_white))); - typedArray.recycle(); - } - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - timerView.stopAnimation(); - } - - public void setMessageRecord(@NonNull MessageRecord messageRecord, @NonNull Locale locale) { - presentDate(messageRecord, locale); - presentTimer(messageRecord); - presentInsecureIndicator(messageRecord); - presentDeliveryStatus(messageRecord); - } - - public void setTextColor(int color) { - dateView.setTextColor(color); - } - - public void setIconColor(int color) { - timerView.setColorFilter(color); - insecureIndicatorView.setColorFilter(color); - deliveryStatusView.setTint(color); - } - - private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) { - dateView.forceLayout(); - - if (messageRecord.isFailed()) { - dateView.setText(R.string.ConversationItem_error_not_delivered); - } else { - dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp())); - } - } - - @SuppressLint("StaticFieldLeak") - private void presentTimer(@NonNull final MessageRecord messageRecord) { - if (messageRecord.getExpiresIn() > 0 && !messageRecord.isPending()) { - this.timerView.setVisibility(View.VISIBLE); - this.timerView.setPercentComplete(0); - - if (messageRecord.getExpireStarted() > 0) { - this.timerView.setExpirationTime(messageRecord.getExpireStarted(), - messageRecord.getExpiresIn()); - this.timerView.startAnimation(); - - if (messageRecord.getExpireStarted() + messageRecord.getExpiresIn() <= SnodeAPI.getNowWithOffset()) { - ApplicationContext.getInstance(getContext()).getExpiringMessageManager().checkSchedule(); - } - } else if (!messageRecord.isOutgoing() && !messageRecord.isMediaPending()) { - new AsyncTask<Void, Void, Void>() { - @Override - protected Void doInBackground(Void... params) { - ExpiringMessageManager expirationManager = ApplicationContext.getInstance(getContext()).getExpiringMessageManager(); - long id = messageRecord.getId(); - boolean mms = messageRecord.isMms(); - - if (mms) DatabaseComponent.get(getContext()).mmsDatabase().markExpireStarted(id); - else DatabaseComponent.get(getContext()).smsDatabase().markExpireStarted(id); - - expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn()); - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - } else { - this.timerView.setVisibility(View.GONE); - } - } - - private void presentInsecureIndicator(@NonNull MessageRecord messageRecord) { - insecureIndicatorView.setVisibility(View.GONE); - } - - private void presentDeliveryStatus(@NonNull MessageRecord messageRecord) { - if (!messageRecord.isFailed()) { - if (!messageRecord.isOutgoing()) deliveryStatusView.setNone(); - else if (messageRecord.isPending()) deliveryStatusView.setPending(); - else if (messageRecord.isRead()) deliveryStatusView.setRead(); - else if (messageRecord.isDelivered()) deliveryStatusView.setDelivered(); - else deliveryStatusView.setSent(); - } else { - deliveryStatusView.setNone(); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt index d0b101a9f1..700534fad1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt @@ -1,13 +1,17 @@ package org.thoughtcrime.securesms.components.menu +import android.content.Context import androidx.annotation.AttrRes +import androidx.annotation.ColorRes /** * Represents an action to be rendered */ -data class ActionItem @JvmOverloads constructor( +data class ActionItem( @AttrRes val iconRes: Int, - val title: CharSequence, + val title: Int, val action: Runnable, - val contentDescription: String? = null + val contentDescription: Int? = null, + val subtitle: ((Context) -> CharSequence?)? = null, + @ColorRes val color: Int? = null, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt index c86b40dfa5..31870e9b7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ContextMenuList.kt @@ -1,12 +1,21 @@ package org.thoughtcrime.securesms.components.menu +import android.content.Context +import android.content.res.ColorStateList import android.util.TypedValue import android.view.View import android.widget.ImageView import android.widget.TextView import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import androidx.core.widget.ImageViewCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import network.loki.messenger.R import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter @@ -34,30 +43,23 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) { mappingAdapter.submitList(items.toAdapterItems()) } - private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> { - return this.mapIndexed { index, item -> - val displayType: DisplayType = when { - this.size == 1 -> DisplayType.ONLY + private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> = + mapIndexed { index, item -> + when { + size == 1 -> DisplayType.ONLY index == 0 -> DisplayType.TOP - index == this.size - 1 -> DisplayType.BOTTOM + index == size - 1 -> DisplayType.BOTTOM else -> DisplayType.MIDDLE - } - - DisplayItem(item, displayType) + }.let { DisplayItem(item, it) } } - } private data class DisplayItem( val item: ActionItem, val displayType: DisplayType ) : MappingModel<DisplayItem> { - override fun areItemsTheSame(newItem: DisplayItem): Boolean { - return this == newItem - } + override fun areItemsTheSame(newItem: DisplayItem): Boolean = this == newItem - override fun areContentsTheSame(newItem: DisplayItem): Boolean { - return this == newItem - } + override fun areContentsTheSame(newItem: DisplayItem): Boolean = this == newItem } private enum class DisplayType { @@ -68,28 +70,61 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) { itemView: View, private val onItemClick: () -> Unit, ) : MappingViewHolder<DisplayItem>(itemView) { + private var subtitleJob: Job? = null val icon: ImageView = itemView.findViewById(R.id.context_menu_item_icon) val title: TextView = itemView.findViewById(R.id.context_menu_item_title) + val subtitle: TextView = itemView.findViewById(R.id.context_menu_item_subtitle) override fun bind(model: DisplayItem) { - if (model.item.iconRes > 0) { + val item = model.item + val color = item.color?.let { ContextCompat.getColor(context, it) } + + if (item.iconRes > 0) { val typedValue = TypedValue() - context.theme.resolveAttribute(model.item.iconRes, typedValue, true) + context.theme.resolveAttribute(item.iconRes, typedValue, true) icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId)) + + icon.imageTintList = color?.let(ColorStateList::valueOf) } - itemView.contentDescription = model.item.contentDescription - title.text = model.item.title + item.contentDescription?.let(context.resources::getString)?.let { itemView.contentDescription = it } + title.setText(item.title) + color?.let(title::setTextColor) + color?.let(subtitle::setTextColor) + subtitle.isGone = true + item.subtitle?.let { startSubtitleJob(subtitle, it) } itemView.setOnClickListener { - model.item.action.run() + item.action.run() onItemClick() } when (model.displayType) { - DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_top) - DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_bottom) - DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_middle) - DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.context_menu_item_background_only) + DisplayType.TOP -> R.drawable.context_menu_item_background_top + DisplayType.BOTTOM -> R.drawable.context_menu_item_background_bottom + DisplayType.MIDDLE -> R.drawable.context_menu_item_background_middle + DisplayType.ONLY -> R.drawable.context_menu_item_background_only + }.let(itemView::setBackgroundResource) + } + + private fun startSubtitleJob(textView: TextView, getSubtitle: (Context) -> CharSequence?) { + fun updateText() = getSubtitle(context).let { + textView.isGone = it == null + textView.text = it } + updateText() + + subtitleJob?.cancel() + subtitleJob = CoroutineScope(Dispatchers.Main).launch { + while (true) { + updateText() + delay(200) + } + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + // naive job cancellation, will break if many items are added to context menu. + subtitleJob?.cancel() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt new file mode 100644 index 0000000000..4bbcc3e021 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActionBarView.kt @@ -0,0 +1,184 @@ +package org.thoughtcrime.securesms.conversation + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback +import com.google.android.material.tabs.TabLayoutMediator +import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R +import network.loki.messenger.databinding.ViewConversationActionBarBinding +import network.loki.messenger.databinding.ViewConversationSettingBinding +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.utilities.ExpirationUtil +import org.session.libsession.utilities.modifyLayoutParams +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.LokiAPIDatabase +import org.thoughtcrime.securesms.util.DateUtils +import java.util.Locale +import javax.inject.Inject + +@AndroidEntryPoint +class ConversationActionBarView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + private val binding = ViewConversationActionBarBinding.inflate(LayoutInflater.from(context), this, true) + + @Inject lateinit var lokiApiDb: LokiAPIDatabase + @Inject lateinit var groupDb: GroupDatabase + + var delegate: ConversationActionBarDelegate? = null + + private val settingsAdapter = ConversationSettingsAdapter { setting -> + if (setting.settingType == ConversationSettingType.EXPIRATION) { + delegate?.onDisappearingMessagesClicked() + } + } + + init { + var previousState: Int + var currentState = 0 + binding.settingsPager.registerOnPageChangeCallback(object : OnPageChangeCallback() { + override fun onPageScrollStateChanged(state: Int) { + val currentPage: Int = binding.settingsPager.currentItem + val lastPage = maxOf( (binding.settingsPager.adapter?.itemCount ?: 0) - 1, 0) + if (currentPage == lastPage || currentPage == 0) { + previousState = currentState + currentState = state + if (previousState == 1 && currentState == 0) { + binding.settingsPager.setCurrentItem(if (currentPage == 0) lastPage else 0, true) + } + } + } + }) + binding.settingsPager.adapter = settingsAdapter + TabLayoutMediator(binding.settingsTabLayout, binding.settingsPager) { _, _ -> }.attach() + } + + fun bind( + delegate: ConversationActionBarDelegate, + threadId: Long, + recipient: Recipient, + config: ExpirationConfiguration? = null, + openGroup: OpenGroup? = null + ) { + this.delegate = delegate + binding.profilePictureView.layoutParams = resources.getDimensionPixelSize( + if (recipient.isClosedGroupRecipient) R.dimen.medium_profile_picture_size else R.dimen.small_profile_picture_size + ).let { LayoutParams(it, it) } + MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadId, context) + update(recipient, openGroup, config) + } + + fun update(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) { + binding.profilePictureView.update(recipient) + binding.conversationTitleView.text = recipient.takeUnless { it.isLocalNumber }?.toShortString() ?: context.getString(R.string.note_to_self) + updateSubtitle(recipient, openGroup, config) + + binding.conversationTitleContainer.modifyLayoutParams<MarginLayoutParams> { + marginEnd = if (recipient.showCallMenu()) 0 else binding.profilePictureView.width + } + } + + fun updateSubtitle(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) { + val settings = mutableListOf<ConversationSetting>() + if (config?.isEnabled == true) { + val prefix = when (config.expiryMode) { + is ExpiryMode.AfterRead -> R.string.expiration_type_disappear_after_read + else -> R.string.expiration_type_disappear_after_send + }.let(context::getString) + settings += ConversationSetting( + "$prefix - ${ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, config.expiryMode.expirySeconds)}", + ConversationSettingType.EXPIRATION, + R.drawable.ic_timer, + resources.getString(R.string.AccessibilityId_disappearing_messages_type_and_time) + ) + } + if (recipient.isMuted) { + settings += ConversationSetting( + recipient.mutedUntil.takeUnless { it == Long.MAX_VALUE } + ?.let { context.getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(it, "EEE, MMM d, yyyy HH:mm", Locale.getDefault())) } + ?: context.getString(R.string.ConversationActivity_muted_forever), + ConversationSettingType.NOTIFICATION, + R.drawable.ic_outline_notifications_off_24 + ) + } + if (recipient.isGroupRecipient) { + val title = if (recipient.isOpenGroupRecipient) { + val userCount = openGroup?.let { lokiApiDb.getUserCount(it.room, it.server) } ?: 0 + context.getString(R.string.ConversationActivity_active_member_count, userCount) + } else { + val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size + context.getString(R.string.ConversationActivity_member_count, userCount) + } + settings += ConversationSetting(title, ConversationSettingType.MEMBER_COUNT) + } + settingsAdapter.submitList(settings) + binding.settingsTabLayout.isVisible = settings.size > 1 + } + + class ConversationSettingsAdapter( + private val settingsListener: (ConversationSetting) -> Unit + ) : ListAdapter<ConversationSetting, ConversationSettingsAdapter.SettingViewHolder>(SettingsDiffer()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + return SettingViewHolder(ViewConversationSettingBinding.inflate(layoutInflater, parent, false)) + } + + override fun onBindViewHolder(holder: SettingViewHolder, position: Int) { + holder.bind(getItem(position), itemCount) { + settingsListener.invoke(it) + } + } + + class SettingViewHolder( + private val binding: ViewConversationSettingBinding + ): RecyclerView.ViewHolder(binding.root) { + + fun bind(setting: ConversationSetting, itemCount: Int, listener: (ConversationSetting) -> Unit) { + binding.root.setOnClickListener { listener.invoke(setting) } + binding.root.contentDescription = setting.contentDescription + binding.iconImageView.setImageResource(setting.iconResId) + binding.iconImageView.isVisible = setting.iconResId > 0 + binding.titleView.text = setting.title + binding.leftArrowImageView.isVisible = itemCount > 1 + binding.rightArrowImageView.isVisible = itemCount > 1 + } + } + + class SettingsDiffer: DiffUtil.ItemCallback<ConversationSetting>() { + override fun areItemsTheSame(oldItem: ConversationSetting, newItem: ConversationSetting): Boolean = oldItem.settingType === newItem.settingType + override fun areContentsTheSame(oldItem: ConversationSetting, newItem: ConversationSetting): Boolean = oldItem == newItem + } + } +} + +fun interface ConversationActionBarDelegate { + fun onDisappearingMessagesClicked() +} + +data class ConversationSetting( + val title: String, + val settingType: ConversationSettingType, + val iconResId: Int = 0, + val contentDescription: String = "" +) + +enum class ConversationSettingType { + EXPIRATION, + MEMBER_COUNT, + NOTIFICATION +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt new file mode 100644 index 0000000000..d336c967ce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +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.ExpirationUtil +import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.getExpirationTypeDisplayValue +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +class DisappearingMessages @Inject constructor( + @ApplicationContext private val context: Context, + private val textSecurePreferences: TextSecurePreferences, + private val messageExpirationManager: MessageExpirationManagerProtocol, +) { + fun set(threadId: Long, address: Address, mode: ExpiryMode, isGroup: Boolean) { + val expiryChangeTimestampMs = SnodeAPI.nowWithOffset + MessagingModuleConfiguration.shared.storage.setExpirationConfiguration(ExpirationConfiguration(threadId, mode, expiryChangeTimestampMs)) + + val message = ExpirationTimerUpdate(isGroup = isGroup).apply { + expiryMode = mode + sender = textSecurePreferences.getLocalNumber() + isSenderSelf = true + recipient = address.serialize() + sentTimestamp = expiryChangeTimestampMs + } + + messageExpirationManager.insertExpirationTimerMessage(message) + MessageSender.send(message, address) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + + fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog { + title(R.string.dialog_disappearing_messages_follow_setting_title) + text(if (message.expiresIn == 0L) { + context.getString(R.string.dialog_disappearing_messages_follow_setting_off_body) + } else { + context.getString( + R.string.dialog_disappearing_messages_follow_setting_on_body, + ExpirationUtil.getExpirationDisplayValue( + context, + message.expiresIn.milliseconds + ), + context.getExpirationTypeDisplayValue(message.isNotDisappearAfterRead) + ) + }) + destructiveButton( + text = if (message.expiresIn == 0L) R.string.dialog_disappearing_messages_follow_setting_confirm else R.string.dialog_disappearing_messages_follow_setting_set, + contentDescription = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_set_button + ) { + set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isClosedGroupRecipient) + } + cancelButton() + } +} + +val MessageRecord.expiryMode get() = if (expiresIn <= 0) ExpiryMode.NONE + else if (expireStarted == timestamp) ExpiryMode.AfterSend(expiresIn / 1000) + else ExpiryMode.AfterRead(expiresIn / 1000) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt new file mode 100644 index 0000000000..16e74cdde9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityDisappearingMessagesBinding +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessages +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.ui.AppTheme +import javax.inject.Inject + +@AndroidEntryPoint +class DisappearingMessagesActivity: PassphraseRequiredActionBarActivity() { + + private lateinit var binding : ActivityDisappearingMessagesBinding + + @Inject lateinit var recipientDb: RecipientDatabase + @Inject lateinit var threadDb: ThreadDatabase + @Inject lateinit var viewModelFactory: DisappearingMessagesViewModel.AssistedFactory + + private val threadId: Long by lazy { + intent.getLongExtra(THREAD_ID, -1) + } + + private val viewModel: DisappearingMessagesViewModel by viewModels { + viewModelFactory.create(threadId) + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) + binding = ActivityDisappearingMessagesBinding.inflate(layoutInflater) + setContentView(binding.root) + + setUpToolbar() + + binding.container.setContent { DisappearingMessagesScreen() } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.event.collect { + when (it) { + Event.SUCCESS -> finish() + Event.FAIL -> showToast(getString(R.string.DisappearingMessagesActivity_settings_not_updated)) + } + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.state.collect { + supportActionBar?.subtitle = it.subtitle(this@DisappearingMessagesActivity) + } + } + } + } + + private fun showToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + private fun setUpToolbar() { + setSupportActionBar(binding.toolbar) + supportActionBar?.apply { + title = getString(R.string.activity_disappearing_messages_title) + setDisplayHomeAsUpEnabled(true) + setHomeButtonEnabled(true) + } + } + + companion object { + const val THREAD_ID = "thread_id" + } + + @Composable + fun DisappearingMessagesScreen() { + val uiState by viewModel.uiState.collectAsState(UiState()) + AppTheme { + DisappearingMessages(uiState, callbacks = viewModel) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt new file mode 100644 index 0000000000..32e20b73d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt @@ -0,0 +1,129 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.BuildConfig +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.ExpiryCallbacks +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.toUiState +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.ThreadDatabase + +class DisappearingMessagesViewModel( + private val threadId: Long, + private val application: Application, + private val textSecurePreferences: TextSecurePreferences, + private val messageExpirationManager: MessageExpirationManagerProtocol, + private val disappearingMessages: DisappearingMessages, + private val threadDb: ThreadDatabase, + private val groupDb: GroupDatabase, + private val storage: Storage, + isNewConfigEnabled: Boolean, + showDebugOptions: Boolean +) : AndroidViewModel(application), ExpiryCallbacks { + + private val _event = Channel<Event>() + val event = _event.receiveAsFlow() + + private val _state = MutableStateFlow( + State( + isNewConfigEnabled = isNewConfigEnabled, + showDebugOptions = showDebugOptions + ) + ) + val state = _state.asStateFlow() + + val uiState = _state + .map(State::toUiState) + .stateIn(viewModelScope, SharingStarted.Eagerly, UiState()) + + init { + viewModelScope.launch { + val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE + val recipient = threadDb.getRecipientForThreadId(threadId) + val groupRecord = recipient?.takeIf { it.isClosedGroupRecipient } + ?.run { groupDb.getGroup(address.toGroupString()).orNull() } + + _state.update { + it.copy( + address = recipient?.address, + isGroup = groupRecord != null, + isNoteToSelf = recipient?.address?.serialize() == textSecurePreferences.getLocalNumber(), + isSelfAdmin = groupRecord == null || groupRecord.admins.any{ it.serialize() == textSecurePreferences.getLocalNumber() }, + expiryMode = expiryMode, + persistedMode = expiryMode + ) + } + } + } + + override fun setValue(value: ExpiryMode) = _state.update { it.copy(expiryMode = value) } + + override fun onSetClick() = viewModelScope.launch { + val state = _state.value + val mode = state.expiryMode?.coerceLegacyToAfterSend() + val address = state.address + if (address == null || mode == null) { + _event.send(Event.FAIL) + return@launch + } + + disappearingMessages.set(threadId, address, mode, state.isGroup) + + _event.send(Event.SUCCESS) + } + + private fun ExpiryMode.coerceLegacyToAfterSend() = takeUnless { it is ExpiryMode.Legacy } ?: ExpiryMode.AfterSend(expirySeconds) + + @dagger.assisted.AssistedFactory + interface AssistedFactory { + fun create(threadId: Long): Factory + } + + @Suppress("UNCHECKED_CAST") + class Factory @AssistedInject constructor( + @Assisted private val threadId: Long, + private val application: Application, + private val textSecurePreferences: TextSecurePreferences, + private val messageExpirationManager: MessageExpirationManagerProtocol, + private val disappearingMessages: DisappearingMessages, + private val threadDb: ThreadDatabase, + private val groupDb: GroupDatabase, + private val storage: Storage + ) : ViewModelProvider.Factory { + + override fun <T : ViewModel> create(modelClass: Class<T>): T = DisappearingMessagesViewModel( + threadId, + application, + textSecurePreferences, + messageExpirationManager, + disappearingMessages, + threadDb, + groupDb, + storage, + ExpirationConfiguration.isNewConfigEnabled, + BuildConfig.DEBUG + ) as T + } +} + +private fun ExpiryMode.maybeConvertToLegacy(isNewConfigEnabled: Boolean): ExpiryMode = takeIf { isNewConfigEnabled } ?: ExpiryMode.Legacy(expirySeconds) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/State.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/State.kt new file mode 100644 index 0000000000..cc968df398 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/State.kt @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages + +import androidx.annotation.StringRes +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.utilities.Address +import org.thoughtcrime.securesms.ui.GetString +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +enum class Event { + SUCCESS, FAIL +} + +data class State( + val isGroup: Boolean = false, + val isSelfAdmin: Boolean = true, + val address: Address? = null, + val isNoteToSelf: Boolean = false, + val expiryMode: ExpiryMode? = null, + val isNewConfigEnabled: Boolean = true, + val persistedMode: ExpiryMode? = null, + val showDebugOptions: Boolean = false +) { + val subtitle get() = when { + isGroup || isNoteToSelf -> GetString(R.string.activity_disappearing_messages_subtitle_sent) + else -> GetString(R.string.activity_disappearing_messages_subtitle) + } + + val typeOptionsHidden get() = isNoteToSelf || (isGroup && isNewConfigEnabled) + + val nextType get() = when { + expiryType == ExpiryType.AFTER_READ -> ExpiryType.AFTER_READ + isNewConfigEnabled -> ExpiryType.AFTER_SEND + else -> ExpiryType.LEGACY + } + + val duration get() = expiryMode?.duration + val expiryType get() = expiryMode?.type + + val isTimeOptionsEnabled = isNoteToSelf || isSelfAdmin && (isNewConfigEnabled || expiryType == ExpiryType.LEGACY) +} + + +enum class ExpiryType( + private val createMode: (Long) -> ExpiryMode, + @StringRes val title: Int, + @StringRes val subtitle: Int? = null, + @StringRes val contentDescription: Int = title, +) { + NONE( + { ExpiryMode.NONE }, + R.string.expiration_off, + contentDescription = R.string.AccessibilityId_disable_disappearing_messages, + ), + LEGACY( + ExpiryMode::Legacy, + R.string.expiration_type_disappear_legacy, + contentDescription = R.string.expiration_type_disappear_legacy_description + ), + AFTER_READ( + ExpiryMode::AfterRead, + R.string.expiration_type_disappear_after_read, + R.string.expiration_type_disappear_after_read_description, + R.string.AccessibilityId_disappear_after_read_option + ), + AFTER_SEND( + ExpiryMode::AfterSend, + R.string.expiration_type_disappear_after_send, + R.string.expiration_type_disappear_after_read_description, + R.string.AccessibilityId_disappear_after_send_option + ); + + fun mode(seconds: Long) = if (seconds != 0L) createMode(seconds) else ExpiryMode.NONE + fun mode(duration: Duration) = mode(duration.inWholeSeconds) + + fun defaultMode(persistedMode: ExpiryMode?) = when(this) { + persistedMode?.type -> persistedMode + AFTER_READ -> mode(12.hours) + else -> mode(1.days) + } +} + +val ExpiryMode.type: ExpiryType get() = when(this) { + is ExpiryMode.Legacy -> ExpiryType.LEGACY + is ExpiryMode.AfterSend -> ExpiryType.AFTER_SEND + is ExpiryMode.AfterRead -> ExpiryType.AFTER_READ + else -> ExpiryType.NONE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt new file mode 100644 index 0000000000..6ddc28c688 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/Adapter.kt @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages.ui + +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.conversation.disappearingmessages.State +import org.thoughtcrime.securesms.ui.GetString +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +fun State.toUiState() = UiState( + cards = listOfNotNull( + typeOptions()?.let { ExpiryOptionsCard(GetString(R.string.activity_disappearing_messages_delete_type), it) }, + timeOptions()?.let { ExpiryOptionsCard(GetString(R.string.activity_disappearing_messages_timer), it) } + ), + showGroupFooter = isGroup && isNewConfigEnabled, + showSetButton = isSelfAdmin +) + +private fun State.typeOptions(): List<ExpiryRadioOption>? = if (typeOptionsHidden) null else { + buildList { + add(offTypeOption()) + if (!isNewConfigEnabled) add(legacyTypeOption()) + if (!isGroup) add(afterReadTypeOption()) + add(afterSendTypeOption()) + } +} + +private fun State.timeOptions(): List<ExpiryRadioOption>? { + // Don't show times card if we have a types card, and type is off. + if (!typeOptionsHidden && expiryType == ExpiryType.NONE) return null + + return nextType.let { type -> + when (type) { + ExpiryType.AFTER_READ -> afterReadTimes + else -> afterSendTimes + }.map { timeOption(type, it) } + }.let { + buildList { + if (typeOptionsHidden) add(offTypeOption()) + addAll(debugOptions()) + addAll(it) + } + } +} + +private fun State.offTypeOption() = typeOption(ExpiryType.NONE) +private fun State.legacyTypeOption() = typeOption(ExpiryType.LEGACY) +private fun State.afterReadTypeOption() = newTypeOption(ExpiryType.AFTER_READ) +private fun State.afterSendTypeOption() = newTypeOption(ExpiryType.AFTER_SEND) +private fun State.newTypeOption(type: ExpiryType) = typeOption(type, isNewConfigEnabled && isSelfAdmin) + +private fun State.typeOption( + type: ExpiryType, + enabled: Boolean = isSelfAdmin, +) = ExpiryRadioOption( + value = type.defaultMode(persistedMode), + title = GetString(type.title), + subtitle = type.subtitle?.let(::GetString), + contentDescription = GetString(type.contentDescription), + selected = expiryType == type, + enabled = enabled +) + +private fun debugTimes(isDebug: Boolean) = if (isDebug) listOf(10.seconds, 30.seconds, 1.minutes) else emptyList() +private fun debugModes(isDebug: Boolean, type: ExpiryType) = + debugTimes(isDebug).map { type.mode(it.inWholeSeconds) } +private fun State.debugOptions(): List<ExpiryRadioOption> = + debugModes(showDebugOptions, nextType).map { timeOption(it, subtitle = GetString("for testing purposes")) } + +private val afterSendTimes = listOf(12.hours, 1.days, 7.days, 14.days) + +private val afterReadTimes = buildList { + add(5.minutes) + add(1.hours) + addAll(afterSendTimes) +} + +private fun State.timeOption( + type: ExpiryType, + time: Duration +) = timeOption(type.mode(time)) + +private fun State.timeOption( + mode: ExpiryMode, + title: GetString = GetString(mode.duration), + subtitle: GetString? = null, +) = ExpiryRadioOption( + value = mode, + title = title, + subtitle = subtitle, + contentDescription = title, + selected = mode.duration == expiryMode?.duration, + enabled = isTimeOptionsEnabled +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt new file mode 100644 index 0000000000..3fec60a0a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.thoughtcrime.securesms.ui.Callbacks +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.NoOpCallbacks +import org.thoughtcrime.securesms.ui.OptionsCard +import org.thoughtcrime.securesms.ui.OutlineButton +import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.fadingEdges + +typealias ExpiryCallbacks = Callbacks<ExpiryMode> +typealias ExpiryRadioOption = RadioOption<ExpiryMode> + +@Composable +fun DisappearingMessages( + state: UiState, + modifier: Modifier = Modifier, + callbacks: ExpiryCallbacks = NoOpCallbacks +) { + val scrollState = rememberScrollState() + + Column(modifier = modifier.padding(horizontal = 32.dp)) { + Box(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier + .padding(bottom = 20.dp) + .verticalScroll(scrollState) + .fadingEdges(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + state.cards.forEach { + OptionsCard(it, callbacks) + } + + if (state.showGroupFooter) Text(text = stringResource(R.string.activity_disappearing_messages_group_footer), + style = TextStyle( + fontSize = 11.sp, + fontWeight = FontWeight(400), + color = Color(0xFFA1A2A1), + textAlign = TextAlign.Center), + modifier = Modifier.fillMaxWidth()) + } + } + + if (state.showSetButton) OutlineButton( + GetString(R.string.disappearing_messages_set_button_title), + modifier = Modifier + .contentDescription(GetString(R.string.AccessibilityId_set_button)) + .align(Alignment.CenterHorizontally) + .padding(bottom = 20.dp), + onClick = callbacks::onSetClick + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt new file mode 100644 index 0000000000..c2524bf261 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages.ui + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.conversation.disappearingmessages.State +import org.thoughtcrime.securesms.ui.PreviewTheme +import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider + +@Preview(widthDp = 450, heightDp = 700) +@Composable +fun PreviewStates( + @PreviewParameter(StatePreviewParameterProvider::class) state: State +) { + PreviewTheme(R.style.Classic_Dark) { + DisappearingMessages( + state.toUiState() + ) + } +} + +class StatePreviewParameterProvider : PreviewParameterProvider<State> { + override val values = newConfigValues.filter { it.expiryType != ExpiryType.LEGACY } + newConfigValues.map { it.copy(isNewConfigEnabled = false) } + + private val newConfigValues get() = sequenceOf( + // new 1-1 + State(expiryMode = ExpiryMode.NONE), + State(expiryMode = ExpiryMode.Legacy(43200)), + State(expiryMode = ExpiryMode.AfterRead(300)), + State(expiryMode = ExpiryMode.AfterSend(43200)), + // new group non-admin + State(isGroup = true, isSelfAdmin = false), + State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.Legacy(43200)), + State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.AfterSend(43200)), + // new group admin + State(isGroup = true), + State(isGroup = true, expiryMode = ExpiryMode.Legacy(43200)), + State(isGroup = true, expiryMode = ExpiryMode.AfterSend(43200)), + // new note-to-self + State(isNoteToSelf = true), + ) +} + +@Preview +@Composable +fun PreviewThemes( + @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int +) { + PreviewTheme(themeResId) { + DisappearingMessages( + State(expiryMode = ExpiryMode.AfterSend(43200)).toUiState(), + modifier = Modifier.size(400.dp, 600.dp) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt new file mode 100644 index 0000000000..40f917427c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages.ui + +import androidx.annotation.StringRes +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.RadioOption + +typealias ExpiryOptionsCard = OptionsCard<ExpiryMode> + +data class UiState( + val cards: List<ExpiryOptionsCard> = emptyList(), + val showGroupFooter: Boolean = false, + val showSetButton: Boolean = true +) { + constructor( + vararg cards: ExpiryOptionsCard, + showGroupFooter: Boolean = false, + showSetButton: Boolean = true, + ): this( + cards.asList(), + showGroupFooter, + showSetButton + ) +} + +data class OptionsCard<T>( + val title: GetString, + val options: List<RadioOption<T>> +) { + constructor(title: GetString, vararg options: RadioOption<T>): this(title, options.asList()) + constructor(@StringRes title: Int, vararg options: RadioOption<T>): this(GetString(title), options.asList()) +} 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 e1515109f8..5fb295dcdd 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 @@ -30,13 +30,11 @@ 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.core.text.set import androidx.core.text.toSpannable import androidx.core.view.drawToBitmap @@ -46,6 +44,7 @@ import androidx.fragment.app.DialogFragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.loader.app.LoaderManager @@ -60,11 +59,13 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.databinding.ViewVisibleMessageBinding +import network.loki.messenger.libsession_util.util.ExpiryMode import nl.komponents.kovenant.ui.successUi import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact @@ -72,8 +73,9 @@ import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.mentions.Mention import org.session.libsession.messaging.mentions.MentionsManager +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.DataExtractionNotification -import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.Reaction @@ -105,6 +107,8 @@ import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.audio.AudioRecorder import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey +import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate +import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity 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 @@ -126,19 +130,16 @@ import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDel import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel 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.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 @@ -166,7 +167,6 @@ 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.showExpirationDialog import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities @@ -176,8 +176,11 @@ import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.SimpleTextWatcher import org.thoughtcrime.securesms.util.isScrolledToBottom import org.thoughtcrime.securesms.util.push +import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.toPx import java.lang.ref.WeakReference +import java.time.Instant +import java.util.Date import java.util.Locale import java.util.concurrent.ExecutionException import java.util.concurrent.atomic.AtomicBoolean @@ -188,6 +191,10 @@ import kotlin.math.abs import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sqrt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +private const val TAG = "ConversationActivityV2" // Some things that seemingly belong to the input bar (e.g. the voice message recording UI) are actually // part of the conversation activity layout. This is just because it makes the layout a lot simpler. The @@ -196,7 +203,7 @@ import kotlin.math.sqrt class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDelegate, InputBarRecordingViewDelegate, AttachmentManager.AttachmentListener, ActivityDispatcher, ConversationActionModeCallbackDelegate, VisibleMessageViewDelegate, RecipientModifiedListener, - SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>, + SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>, ConversationActionBarDelegate, OnReactionSelectedListener, ReactWithAnyEmojiDialogFragment.Callback, ReactionsDialogFragment.Callback, ConversationMenuHelper.ConversationMenuListener { @@ -208,8 +215,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe @Inject lateinit var lokiThreadDb: LokiThreadDatabase @Inject lateinit var sessionContactDb: SessionContactDatabase @Inject lateinit var groupDb: GroupDatabase - @Inject lateinit var recipientDb: RecipientDatabase - @Inject lateinit var lokiApiDb: LokiAPIDatabase @Inject lateinit var smsDb: SmsDatabase @Inject lateinit var mmsDb: MmsDatabase @Inject lateinit var lokiMessageDb: LokiMessageDatabase @@ -410,7 +415,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } updateUnreadCountIndicator() - updateSubtitle() updatePlaceholder() setUpBlockedBanner() binding!!.searchBottomBar.setEventListener(this) @@ -438,6 +442,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe setUpRecipientObserver() getLatestOpenGroupInfoIfNeeded() setUpSearchResultObserver() + scrollToFirstUnreadMessageIfNeeded() + setUpOutdatedClientBanner() if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { binding?.conversationRecyclerView?.scrollToPosition(targetPosition) @@ -453,18 +459,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe 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) + bufferedLastSeenChannel.receiveAsFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) + .collectLatest { + withContext(Dispatchers.IO) { + try { + if (it > storage.getLastSeen(viewModel.threadId)) { + storage.markConversationAsRead(viewModel.threadId, it) + } + } catch (e: Exception) { + Log.e(TAG, "bufferedLastSeenChannel collectLatest", e) + } + } } - } - } } } @@ -477,6 +486,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe true, screenshotObserver ) + viewModel.run { + binding?.toolbarContent?.update(recipient ?: return, openGroup, expirationConfiguration) + } } override fun onPause() { @@ -493,8 +505,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun dispatchIntent(body: (Context) -> Intent?) { - val intent = body(this) ?: return - push(intent, false) + body(this)?.let { push(it, false) } } override fun showDialog(dialogFragment: DialogFragment, tag: String?) { @@ -526,16 +537,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (author != null && messageTimestamp >= 0) { jumpToMessage(author, messageTimestamp, firstLoad.get(), null) - } - else if (firstLoad.getAndSet(false)) { - scrollToFirstUnreadMessageIfNeeded(true) - handleRecyclerViewScrolled() - } - else if (oldCount != newCount) { + } else { + if (firstLoad.getAndSet(false)) scrollToFirstUnreadMessageIfNeeded(true) handleRecyclerViewScrolled() } } updatePlaceholder() + viewModel.recipient?.let { + maybeUpdateToolbar(recipient = it) + setUpOutdatedClientBanner() + } } override fun onLoaderReset(cursor: Loader<Cursor>) { @@ -574,20 +585,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe actionBar.title = "" actionBar.setDisplayHomeAsUpEnabled(true) actionBar.setHomeButtonEnabled(true) - binding.toolbarContent.conversationTitleView.text = when { - recipient.isLocalNumber -> getString(R.string.note_to_self) - else -> recipient.toShortString() - } - @DimenRes val sizeID: Int = if (viewModel.recipient?.isClosedGroupRecipient == true) { - R.dimen.medium_profile_picture_size - } else { - R.dimen.small_profile_picture_size - } - val size = resources.getDimension(sizeID).roundToInt() - binding.toolbarContent.profilePictureView.layoutParams = LinearLayout.LayoutParams(size, size) - MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this) - val profilePictureView = binding.toolbarContent.profilePictureView - viewModel.recipient?.let(profilePictureView::update) + binding!!.toolbarContent.bind( + this, + viewModel.threadId, + recipient, + viewModel.expirationConfiguration, + viewModel.openGroup + ) + maybeUpdateToolbar(recipient) } // called from onCreate @@ -679,23 +684,36 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun getLatestOpenGroupInfoIfNeeded() { - viewModel.openGroup?.let { - OpenGroupApi.getMemberCount(it.room, it.server).successUi { updateSubtitle() } + val openGroup = viewModel.openGroup ?: return + OpenGroupApi.getMemberCount(openGroup.room, openGroup.server) successUi { + binding?.toolbarContent?.updateSubtitle(viewModel.recipient!!, openGroup, viewModel.expirationConfiguration) + maybeUpdateToolbar(viewModel.recipient!!) } } // called from onCreate private fun setUpBlockedBanner() { - val recipient = viewModel.recipient ?: return - if (recipient.isGroupRecipient) { return } + val recipient = viewModel.recipient?.takeUnless { it.isGroupRecipient } ?: return val sessionID = recipient.address.toString() - val contact = sessionContactDb.getContactWithSessionID(sessionID) - val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID + val name = sessionContactDb.getContactWithSessionID(sessionID)?.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() } } + private fun setUpOutdatedClientBanner() { + val legacyRecipient = viewModel.legacyBannerRecipient(this) + + val shouldShowLegacy = ExpirationConfiguration.isNewConfigEnabled && + legacyRecipient != null + + binding?.outdatedBanner?.isVisible = shouldShowLegacy + if (shouldShowLegacy) { + binding?.outdatedBannerTextView?.text = + resources.getString(R.string.activity_conversation_outdated_client_banner_text, legacyRecipient!!.name) + } + } + private fun setUpLinkPreviewObserver() { if (!textSecurePreferences.isLinkPreviewsEnabled()) { linkPreviewViewModel.onUserCancel(); return @@ -766,10 +784,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe menu, menuInflater, recipient, - viewModel.threadId, this - ) { onOptionsItemSelected(it) } + ) } + maybeUpdateToolbar(recipient) return true } @@ -778,7 +796,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe tearDownRecipientObserver() super.onDestroy() binding = null -// actionBarBinding = null } // endregion @@ -793,31 +810,25 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } setUpMessageRequestsBar() invalidateOptionsMenu() - updateSubtitle() updateSendAfterApprovalText() showOrHideInputIfNeeded() - binding?.toolbarContent?.profilePictureView?.update(threadRecipient) - binding?.toolbarContent?.conversationTitleView?.text = when { - threadRecipient.isLocalNumber -> getString(R.string.note_to_self) - else -> threadRecipient.toShortString() - } + maybeUpdateToolbar(threadRecipient) } } + private fun maybeUpdateToolbar(recipient: Recipient) { + binding?.toolbarContent?.update(recipient, viewModel.openGroup, viewModel.expirationConfiguration) + } + private fun updateSendAfterApprovalText() { binding?.textSendAfterApproval?.isVisible = viewModel.showSendAfterApprovalText } private fun showOrHideInputIfNeeded() { - val recipient = viewModel.recipient - if (recipient != null && recipient.isClosedGroupRecipient) { - val group = groupDb.getGroup(recipient.address.toGroupString()).orNull() - val isActive = (group?.isActive == true) - binding?.inputBar?.showInput = isActive - } else { - binding?.inputBar?.showInput = true - } + binding?.inputBar?.showInput = viewModel.recipient?.takeIf { it.isClosedGroupRecipient } + ?.run { address.toGroupString().let(groupDb::getGroup).orNull()?.isActive == true } + ?: true } private fun setUpMessageRequestsBar() { @@ -847,21 +858,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - private fun isOutgoingMessageRequestThread(): Boolean { - val recipient = viewModel.recipient ?: return false - return !recipient.isGroupRecipient && - !recipient.isLocalNumber && - !(recipient.hasApprovedMe() || viewModel.hasReceived()) - } + private fun isOutgoingMessageRequestThread(): Boolean = viewModel.recipient?.run { + !isGroupRecipient && !isLocalNumber && + !(hasApprovedMe() || viewModel.hasReceived()) + } ?: false - private fun isIncomingMessageRequestThread(): Boolean { - val recipient = viewModel.recipient ?: return false - return !recipient.isGroupRecipient && - !recipient.isApproved && - !recipient.isLocalNumber && - !threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && - threadDb.getMessageCount(viewModel.threadId) > 0 - } + private fun isIncomingMessageRequestThread(): Boolean = viewModel.recipient?.run { + !isGroupRecipient && !isApproved && !isLocalNumber && + !threadDb.getLastSeenAndHasSent(viewModel.threadId).second() && threadDb.getMessageCount(viewModel.threadId) > 0 + } ?: false override fun inputBarEditTextContentChanged(newContent: CharSequence) { val inputBarText = binding?.inputBar?.text ?: return // TODO check if we should be referencing newContent here instead @@ -1041,16 +1046,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe 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) + adapter.getTimestampForItemAt(targetVisiblePosition)?.let { visibleItemTimestamp -> + bufferedLastSeenChannel.trySend(visibleItemTimestamp).apply { + if (isFailure) Log.e(TAG, "trySend failed", exceptionOrNull()) + } } } if (reverseMessageList) { unreadCount = min(unreadCount, targetVisiblePosition).coerceAtLeast(0) - } - else { + } else { val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() } ?: RecyclerView.NO_POSITION unreadCount = min(unreadCount, layoutUnreadCount).coerceAtLeast(0) @@ -1104,33 +1109,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding.unreadCountIndicator.isVisible = (unreadCount != 0) } - private fun updateSubtitle() { - val actionBarBinding = binding?.toolbarContent ?: return - val recipient = viewModel.recipient ?: return - actionBarBinding.muteIconImageView.isVisible = recipient.isMuted - actionBarBinding.conversationSubtitleView.isVisible = true - if (recipient.isMuted) { - if (recipient.mutedUntil != Long.MAX_VALUE) { - actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(recipient.mutedUntil, "EEE, MMM d, yyyy HH:mm", Locale.getDefault())) - } else { - actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_muted_forever) - } - } else if (recipient.isGroupRecipient) { - viewModel.openGroup?.let { openGroup -> - val userCount = lokiApiDb.getUserCount(openGroup.room, openGroup.server) ?: 0 - actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_active_member_count, userCount) - } ?: run { - val userCount = groupDb.getGroupMemberAddresses(recipient.address.toGroupString(), true).size - actionBarBinding.conversationSubtitleView.text = getString(R.string.ConversationActivity_member_count, userCount) - } - viewModel - } else { - actionBarBinding.conversationSubtitleView.isVisible = false - } - } // endregion // region Interaction + override fun onDisappearingMessagesClicked() { + viewModel.recipient?.let { showDisappearingMessages(it) } + } + override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == android.R.id.home) { return false @@ -1174,20 +1159,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() } - override fun showExpiringMessagesDialog(thread: Recipient) { + override fun showDisappearingMessages(thread: Recipient) { if (thread.isClosedGroupRecipient) { - val group = groupDb.getGroup(thread.address.toGroupString()).orNull() - if (group?.isActive == false) { return } - } - showExpirationDialog(thread.expireMessages) { expirationTime -> - storage.setExpirationTimer(thread.address.serialize(), expirationTime) - val message = ExpirationTimerUpdate(expirationTime) - message.recipient = thread.address.serialize() - message.sentTimestamp = SnodeAPI.nowWithOffset - ApplicationContext.getInstance(this).expiringMessageManager.setExpirationTimer(message) - MessageSender.send(message, thread.address) - invalidateOptionsMenu() + groupDb.getGroup(thread.address.toGroupString()).orNull()?.run { if (!isActive) return } } + Intent(this, DisappearingMessagesActivity::class.java) + .apply { putExtra(DisappearingMessagesActivity.THREAD_ID, viewModel.threadId) } + .also { show(it, true) } } override fun unblock() { @@ -1584,10 +1562,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe return null } // Create the message - val message = VisibleMessage() + val message = VisibleMessage().applyExpiryMode(viewModel.threadId) message.sentTimestamp = sentTimestamp message.text = text - val outgoingTextMessage = OutgoingTextMessage.from(message, recipient) + val expiresInMillis = viewModel.expirationConfiguration?.expiryMode?.expiryMillis ?: 0 + val expireStartedAt = if (viewModel.expirationConfiguration?.expiryMode is ExpiryMode.AfterSend) { + message.sentTimestamp!! + } else 0 + val outgoingTextMessage = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt) // Clear the input bar binding?.inputBar?.text = "" binding?.inputBar?.cancelQuoteDraft() @@ -1605,12 +1587,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe return Pair(recipient.address, sentTimestamp) } - private fun sendAttachments(attachments: List<Attachment>, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null): Pair<Address, Long>? { + private fun sendAttachments( + attachments: List<Attachment>, + body: String?, + quotedMessage: MessageRecord? = binding?.inputBar?.quote, + linkPreview: LinkPreview? = null + ): Pair<Address, Long>? { val recipient = viewModel.recipient ?: return null val sentTimestamp = SnodeAPI.nowWithOffset processMessageRequestApproval() // Create the message - val message = VisibleMessage() + val message = VisibleMessage().applyExpiryMode(viewModel.threadId) message.sentTimestamp = sentTimestamp message.text = body val quote = quotedMessage?.let { @@ -1626,7 +1613,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe else it.individualRecipient.address quote?.copy(author = sender) } - val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview) + val expiresInMs = viewModel.expirationConfiguration?.expiryMode?.expiryMillis ?: 0 + val expireStartedAtMs = if (viewModel.expirationConfiguration?.expiryMode is ExpiryMode.AfterSend) { + sentTimestamp + } else 0 + val outgoingTextMessage = OutgoingMediaMessage.from(message, recipient, attachments, localQuote, linkPreview, expiresInMs, expireStartedAtMs) // Clear the input bar binding?.inputBar?.text = "" binding?.inputBar?.cancelQuoteDraft() @@ -1691,6 +1682,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) } + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { super.onActivityResult(requestCode, resultCode, intent) val mediaPreppedListener = object : ListenableFuture.Listener<Boolean> { @@ -1810,7 +1802,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun deleteMessages(messages: Set<MessageRecord>) { val recipient = viewModel.recipient ?: return val allSentByCurrentUser = messages.all { it.isOutgoing } - val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id) != null } + val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null } if (recipient.isOpenGroupRecipient) { val messageCount = 1 @@ -1943,6 +1935,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe override fun saveAttachment(messages: Set<MessageRecord>) { val message = messages.first() as MmsMessageRecord + + // Do not allow the user to download a file attachment before it has finished downloading + // TODO: Localise the msg in this toast! + if (message.isMediaPending) { + Toast.makeText(this, resources.getString(R.string.conversation_activity__wait_until_attachment_has_finished_downloading), Toast.LENGTH_LONG).show() + return + } + SaveAttachmentTask.showWarningDialog(this) { Permissions.with(this) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) 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 deleted file mode 100644 index eee8b5ecd5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.java +++ /dev/null @@ -1,902 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2; - -import android.animation.Animator; -import android.animation.AnimatorSet; -import android.animation.ObjectAnimator; -import android.animation.ValueAnimator; -import android.app.Activity; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.PointF; -import android.graphics.Rect; -import android.graphics.drawable.BitmapDrawable; -import android.util.AttributeSet; -import android.view.HapticFeedbackConstants; -import android.view.MotionEvent; -import android.view.View; -import android.view.Window; -import android.view.animation.DecelerateInterpolator; -import android.view.animation.Interpolator; -import android.widget.FrameLayout; -import android.widget.LinearLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.core.content.ContextCompat; -import androidx.core.view.ViewKt; -import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat; - -import com.annimon.stream.Stream; - -import org.session.libsession.messaging.open_groups.OpenGroup; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.ThemeUtil; -import org.session.libsession.utilities.recipients.Recipient; -import org.thoughtcrime.securesms.components.emoji.EmojiImageView; -import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel; -import org.thoughtcrime.securesms.components.menu.ActionItem; -import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper; -import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.database.model.ReactionRecord; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.util.AnimationCompleteListener; -import org.thoughtcrime.securesms.util.DateUtils; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Locale; - -import kotlin.Unit; -import network.loki.messenger.R; - -public final class ConversationReactionOverlay extends FrameLayout { - - public static final float LONG_PRESS_SCALE_FACTOR = 0.95f; - private static final Interpolator INTERPOLATOR = new DecelerateInterpolator(); - - private final Rect emojiViewGlobalRect = new Rect(); - private final Rect emojiStripViewBounds = new Rect(); - private float segmentSize; - - private final Boundary horizontalEmojiBoundary = new Boundary(); - private final Boundary verticalScrubBoundary = new Boundary(); - private final PointF deadzoneTouchPoint = new PointF(); - - private Activity activity; - private MessageRecord messageRecord; - private SelectedConversationModel selectedConversationModel; - private String blindedPublicKey; - private OverlayState overlayState = OverlayState.HIDDEN; - private RecentEmojiPageModel recentEmojiPageModel; - - private boolean downIsOurs; - private int selected = -1; - private int customEmojiIndex; - private int originalStatusBarColor; - private int originalNavigationBarColor; - - private View dropdownAnchor; - private LinearLayout conversationItem; - private View conversationBubble; - private TextView conversationTimestamp; - private View backgroundView; - private ConstraintLayout foregroundView; - private EmojiImageView[] emojiViews; - - private ConversationContextMenu contextMenu; - - private float touchDownDeadZoneSize; - private float distanceFromTouchDownPointToBottomOfScrubberDeadZone; - private int scrubberWidth; - private int selectedVerticalTranslation; - private int scrubberHorizontalMargin; - private int animationEmojiStartDelayFactor; - private int statusBarHeight; - - private OnReactionSelectedListener onReactionSelectedListener; - private OnActionSelectedListener onActionSelectedListener; - private OnHideListener onHideListener; - - private AnimatorSet revealAnimatorSet = new AnimatorSet(); - private AnimatorSet hideAnimatorSet = new AnimatorSet(); - - public ConversationReactionOverlay(@NonNull Context context) { - super(context); - } - - public ConversationReactionOverlay(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - - dropdownAnchor = findViewById(R.id.dropdown_anchor); - conversationItem = findViewById(R.id.conversation_item); - conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble); - conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp); - backgroundView = findViewById(R.id.conversation_reaction_scrubber_background); - foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground); - - emojiViews = new EmojiImageView[] { findViewById(R.id.reaction_1), - findViewById(R.id.reaction_2), - findViewById(R.id.reaction_3), - findViewById(R.id.reaction_4), - findViewById(R.id.reaction_5), - findViewById(R.id.reaction_6), - findViewById(R.id.reaction_7) }; - - customEmojiIndex = emojiViews.length - 1; - - distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom); - - touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size); - scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width); - selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation); - scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin); - - animationEmojiStartDelayFactor = getResources().getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor); - - initAnimators(); - } - - public void show(@NonNull Activity activity, - @NonNull MessageRecord messageRecord, - @NonNull PointF lastSeenDownPoint, - @NonNull SelectedConversationModel selectedConversationModel, - @Nullable String blindedPublicKey) - { - if (overlayState != OverlayState.HIDDEN) { - return; - } - - this.messageRecord = messageRecord; - this.selectedConversationModel = selectedConversationModel; - this.blindedPublicKey = blindedPublicKey; - overlayState = OverlayState.UNINITAILIZED; - selected = -1; - recentEmojiPageModel = new RecentEmojiPageModel(activity); - - setupSelectedEmoji(); - - View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground); - statusBarHeight = statusBarBackground == null ? 0 : statusBarBackground.getHeight(); - - Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap(); - - conversationBubble.setLayoutParams(new LinearLayout.LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight())); - conversationBubble.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot)); - conversationTimestamp.setText(DateUtils.getDisplayFormattedTimeSpanString(getContext(), Locale.getDefault(), messageRecord.getTimestamp())); - - updateConversationTimestamp(messageRecord); - - boolean isMessageOnLeft = selectedConversationModel.isOutgoing() ^ ViewUtil.isLtr(this); - - conversationItem.setScaleX(LONG_PRESS_SCALE_FACTOR); - conversationItem.setScaleY(LONG_PRESS_SCALE_FACTOR); - - setVisibility(View.INVISIBLE); - - this.activity = activity; - updateSystemUiOnShow(activity); - - ViewKt.doOnLayout(this, v -> { - showAfterLayout(messageRecord, lastSeenDownPoint, isMessageOnLeft); - return Unit.INSTANCE; - }); - } - - private void updateConversationTimestamp(MessageRecord message) { - if (message.isOutgoing()) conversationBubble.bringToFront(); - else conversationTimestamp.bringToFront(); - } - - private void showAfterLayout(@NonNull MessageRecord messageRecord, - @NonNull PointF lastSeenDownPoint, - boolean isMessageOnLeft) { - contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord)); - - float endX = isMessageOnLeft ? scrubberHorizontalMargin : - selectedConversationModel.getBubbleX() - conversationItem.getWidth() + selectedConversationModel.getBubbleWidth(); - float endY = selectedConversationModel.getBubbleY() - statusBarHeight; - conversationItem.setX(endX); - conversationItem.setY(endY); - - Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap(); - boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth(); - - int overlayHeight = getHeight(); - int bubbleWidth = selectedConversationModel.getBubbleWidth(); - - float endApparentTop = endY; - float endScale = 1f; - - float menuPadding = DimensionUnit.DP.toPixels(12f); - float reactionBarTopPadding = DimensionUnit.DP.toPixels(32f); - int reactionBarHeight = backgroundView.getHeight(); - - float reactionBarBackgroundY; - - if (isWideLayout) { - boolean everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.getHeight() < overlayHeight; - if (everythingFitsVertically) { - boolean reactionBarFitsAboveItem = conversationItem.getY() > reactionBarHeight + menuPadding + reactionBarTopPadding; - - if (reactionBarFitsAboveItem) { - reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight; - } else { - endY = reactionBarHeight + menuPadding + reactionBarTopPadding; - reactionBarBackgroundY = reactionBarTopPadding; - } - } else { - float spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding; - - endScale = spaceAvailableForItem / conversationItem.getHeight(); - endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1); - endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale); - reactionBarBackgroundY = reactionBarTopPadding; - } - } else { - float reactionBarOffset = DimensionUnit.DP.toPixels(48); - float spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset, 0); - boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + spaceForReactionBar < overlayHeight; - - if (everythingFitsVertically) { - float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight(); - boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight; - - if (menuFitsBelowItem) { - if (conversationItem.getY() < 0) { - endY = 0; - } - float contextMenuTop = endY + conversationItemSnapshot.getHeight(); - reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); - - if (reactionBarBackgroundY <= reactionBarTopPadding) { - endY = backgroundView.getHeight() + menuPadding + reactionBarTopPadding; - } - } else { - endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight(); - reactionBarBackgroundY = endY - reactionBarHeight - menuPadding; - } - - endApparentTop = endY; - } else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) { - float spaceAvailableForItem = (float) overlayHeight - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar; - - endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight(); - endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1); - endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale); - - float contextMenuTop = endY + (conversationItemSnapshot.getHeight() * endScale); - reactionBarBackgroundY = reactionBarTopPadding;//getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); - endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale); - } else { - contextMenu.setHeight(contextMenu.getMaxHeight() / 2); - - int menuHeight = contextMenu.getHeight(); - boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight; - - if (fitsVertically) { - float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight(); - boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight; - - if (menuFitsBelowItem) { - reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight; - - if (reactionBarBackgroundY < reactionBarTopPadding) { - endY = reactionBarTopPadding + reactionBarHeight + menuPadding; - reactionBarBackgroundY = reactionBarTopPadding; - } - } else { - endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.getHeight(); - reactionBarBackgroundY = endY - reactionBarHeight - menuPadding; - } - endApparentTop = endY; - } else { - float spaceAvailableForItem = (float) overlayHeight - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding; - - endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight(); - endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1); - endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale) + menuPadding + reactionBarTopPadding; - reactionBarBackgroundY = reactionBarTopPadding; - endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding; - } - } - } - - reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight); - - hideAnimatorSet.end(); - setVisibility(View.VISIBLE); - - float scrubberX; - if (isMessageOnLeft) { - scrubberX = scrubberHorizontalMargin; - } else { - scrubberX = getWidth() - scrubberWidth - scrubberHorizontalMargin; - } - - foregroundView.setX(scrubberX); - foregroundView.setY(reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.getHeight() / 2f); - - backgroundView.setX(scrubberX); - backgroundView.setY(reactionBarBackgroundY); - - verticalScrubBoundary.update(reactionBarBackgroundY, - lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone); - - updateBoundsOnLayoutChanged(); - - revealAnimatorSet.start(); - - if (isWideLayout) { - float scrubberRight = scrubberX + scrubberWidth; - float offsetX = isMessageOnLeft ? scrubberRight + menuPadding : scrubberX - contextMenu.getMaxWidth() - menuPadding; - contextMenu.show((int) offsetX, (int) Math.min(backgroundView.getY(), overlayHeight - contextMenu.getMaxHeight())); - } else { - float contentX = isMessageOnLeft ? scrubberHorizontalMargin : selectedConversationModel.getBubbleX(); - float offsetX = isMessageOnLeft ? contentX : -contextMenu.getMaxWidth() + contentX + bubbleWidth; - - float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale); - contextMenu.show((int) offsetX, (int) (menuTop + menuPadding)); - } - - int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration); - - conversationBubble.animate() - .scaleX(endScale) - .scaleY(endScale) - .setDuration(revealDuration); - - conversationItem.animate() - .x(endX) - .y(endY) - .setDuration(revealDuration); - } - - private float getReactionBarOffsetForTouch(float itemY, - float contextMenuTop, - float contextMenuPadding, - float reactionBarOffset, - int reactionBarHeight, - float spaceNeededBetweenTopOfScreenAndTopOfReactionBar, - float messageTop) - { - float adjustedTouchY = itemY - statusBarHeight; - float reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop); - - float spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop); - - if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150)) { - float offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding; - reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding; - } - - return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar); - } - - private void updateSystemUiOnShow(@NonNull Activity activity) { - Window window = activity.getWindow(); - int barColor = ContextCompat.getColor(getContext(), R.color.reactions_screen_dark_shade_color); - - originalStatusBarColor = window.getStatusBarColor(); - WindowUtil.setStatusBarColor(window, barColor); - - originalNavigationBarColor = window.getNavigationBarColor(); - WindowUtil.setNavigationBarColor(window, barColor); - - if (!ThemeUtil.isDarkTheme(getContext())) { - WindowUtil.clearLightStatusBar(window); - WindowUtil.clearLightNavigationBar(window); - } - } - - public void hide() { - hideInternal(onHideListener); - } - - public void hideForReactWithAny() { - hideInternal(onHideListener); - } - - private void hideInternal(@Nullable OnHideListener onHideListener) { - overlayState = OverlayState.HIDDEN; - - AnimatorSet animatorSet = newHideAnimatorSet(); - hideAnimatorSet = animatorSet; - - revealAnimatorSet.end(); - animatorSet.start(); - - if (onHideListener != null) { - onHideListener.startHide(); - } - - if (selectedConversationModel.getFocusedView() != null) { - ViewUtil.focusAndShowKeyboard(selectedConversationModel.getFocusedView()); - } - - animatorSet.addListener(new AnimationCompleteListener() { - @Override public void onAnimationEnd(Animator animation) { - animatorSet.removeListener(this); - - if (onHideListener != null) { - onHideListener.onHide(); - } - } - }); - - if (contextMenu != null) { - contextMenu.dismiss(); - } - } - - public boolean isShowing() { - return overlayState != OverlayState.HIDDEN; - } - - public @NonNull MessageRecord getMessageRecord() { - return messageRecord; - } - - @Override - protected void onLayout(boolean changed, int l, int t, int r, int b) { - super.onLayout(changed, l, t, r, b); - - updateBoundsOnLayoutChanged(); - } - - private void updateBoundsOnLayoutChanged() { - backgroundView.getGlobalVisibleRect(emojiStripViewBounds); - emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect); - emojiStripViewBounds.left = getStart(emojiViewGlobalRect); - emojiViews[emojiViews.length - 1].getGlobalVisibleRect(emojiViewGlobalRect); - emojiStripViewBounds.right = getEnd(emojiViewGlobalRect); - - segmentSize = emojiStripViewBounds.width() / (float) emojiViews.length; - } - - private int getStart(@NonNull Rect rect) { - if (ViewUtil.isLtr(this)) { - return rect.left; - } else { - return rect.right; - } - } - - private int getEnd(@NonNull Rect rect) { - if (ViewUtil.isLtr(this)) { - return rect.right; - } else { - return rect.left; - } - } - - public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) { - if (!isShowing()) { - throw new IllegalStateException("Touch events should only be propagated to this method if we are displaying the scrubber."); - } - - if ((motionEvent.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) != 0) { - return true; - } - - if (overlayState == OverlayState.UNINITAILIZED) { - downIsOurs = false; - - deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY()); - - overlayState = OverlayState.DEADZONE; - } - - if (overlayState == OverlayState.DEADZONE) { - float deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.getX()); - float deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.getY()); - - if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) { - overlayState = OverlayState.SCRUB; - } else { - if (motionEvent.getAction() == MotionEvent.ACTION_UP) { - overlayState = OverlayState.TAP; - - if (downIsOurs) { - handleUpEvent(); - return true; - } - } - - return MotionEvent.ACTION_MOVE == motionEvent.getAction(); - } - } - - switch (motionEvent.getAction()) { - case MotionEvent.ACTION_DOWN: - selected = getSelectedIndexViaDownEvent(motionEvent); - - deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY()); - overlayState = OverlayState.DEADZONE; - downIsOurs = true; - return true; - case MotionEvent.ACTION_MOVE: - selected = getSelectedIndexViaMoveEvent(motionEvent); - return true; - case MotionEvent.ACTION_UP: - handleUpEvent(); - return downIsOurs; - case MotionEvent.ACTION_CANCEL: - hide(); - return downIsOurs; - default: - return false; - } - } - - private void setupSelectedEmoji() { - final List<String> emojis = recentEmojiPageModel.getEmoji(); - - for (int i = 0; i < emojiViews.length; i++) { - final EmojiImageView view = emojiViews[i]; - - view.setScaleX(1.0f); - view.setScaleY(1.0f); - view.setTranslationY(0); - - boolean isAtCustomIndex = i == customEmojiIndex; - - if (isAtCustomIndex) { - view.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_baseline_add_24)); - view.setTag(null); - } else { - view.setImageEmoji(emojis.get(i)); - } - } - } - - private int getSelectedIndexViaDownEvent(@NonNull MotionEvent motionEvent) { - return getSelectedIndexViaMotionEvent(motionEvent, new Boundary(emojiStripViewBounds.top, emojiStripViewBounds.bottom)); - } - - private int getSelectedIndexViaMoveEvent(@NonNull MotionEvent motionEvent) { - return getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary); - } - - private int getSelectedIndexViaMotionEvent(@NonNull MotionEvent motionEvent, @NonNull Boundary boundary) { - int selected = -1; - - if (backgroundView.getVisibility() != View.VISIBLE) { - return selected; - } - - for (int i = 0; i < emojiViews.length; i++) { - final float emojiLeft = (segmentSize * i) + emojiStripViewBounds.left; - horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize); - - if (horizontalEmojiBoundary.contains(motionEvent.getX()) && boundary.contains(motionEvent.getY())) { - selected = i; - } - } - - if (this.selected != -1 && this.selected != selected) { - shrinkView(emojiViews[this.selected]); - } - - if (this.selected != selected && selected != -1) { - growView(emojiViews[selected]); - } - - return selected; - } - - private void growView(@NonNull View view) { - view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); - view.animate() - .scaleY(1.5f) - .scaleX(1.5f) - .translationY(-selectedVerticalTranslation) - .setDuration(200) - .setInterpolator(INTERPOLATOR) - .start(); - } - - private void shrinkView(@NonNull View view) { - view.animate() - .scaleX(1.0f) - .scaleY(1.0f) - .translationY(0) - .setDuration(200) - .setInterpolator(INTERPOLATOR) - .start(); - } - - private void handleUpEvent() { - if (selected != -1 && onReactionSelectedListener != null && backgroundView.getVisibility() == View.VISIBLE) { - if (selected == customEmojiIndex) { - onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null); - } else { - onReactionSelectedListener.onReactionSelected(messageRecord, recentEmojiPageModel.getEmoji().get(selected)); - } - } else { - hide(); - } - } - - public void setOnReactionSelectedListener(@Nullable OnReactionSelectedListener onReactionSelectedListener) { - this.onReactionSelectedListener = onReactionSelectedListener; - } - - public void setOnActionSelectedListener(@Nullable OnActionSelectedListener onActionSelectedListener) { - this.onActionSelectedListener = onActionSelectedListener; - } - - public void setOnHideListener(@Nullable OnHideListener onHideListener) { - this.onHideListener = onHideListener; - } - - private @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) { - return Stream.of(messageRecord.getReactions()) - .filter(record -> record.getAuthor().equals(TextSecurePreferences.getLocalNumber(getContext()))) - .findFirst() - .map(ReactionRecord::getEmoji) - .orElse(null); - } - - private @NonNull List<ActionItem> getMenuActionItems(@NonNull MessageRecord message) { - List<ActionItem> items = new ArrayList<>(); - - // Prepare - boolean containsControlMessage = message.isUpdate(); - boolean hasText = !message.getBody().isEmpty(); - OpenGroup openGroup = DatabaseComponent.get(getContext()).lokiThreadDatabase().getOpenGroupChat(message.getThreadId()); - Recipient recipient = DatabaseComponent.get(getContext()).threadDatabase().getRecipientForThreadId(message.getThreadId()); - if (recipient == null) return Collections.emptyList(); - - String userPublicKey = TextSecurePreferences.getLocalNumber(getContext()); - // Select message - items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT), - getContext().getResources().getString(R.string.AccessibilityId_select))); - // Reply - boolean canWrite = openGroup == null || openGroup.getCanWrite(); - if (canWrite && !message.isPending() && !message.isFailed()) { - items.add( - new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY), - getContext().getResources().getString(R.string.AccessibilityId_reply_message)) - ); - } - // Copy message text - if (!containsControlMessage && hasText) { - items.add(new ActionItem(R.attr.menu_copy_icon, getContext().getResources().getString(R.string.copy), () -> handleActionItemClicked(Action.COPY_MESSAGE))); - } - // Copy Session ID - if (recipient.isGroupRecipient() && !recipient.isOpenGroupRecipient() && !message.getRecipient().getAddress().toString().equals(userPublicKey)) { - items.add(new ActionItem( - R.attr.menu_copy_icon, getContext().getResources().getString(R.string.activity_conversation_menu_copy_session_id), () -> handleActionItemClicked(Action.COPY_SESSION_ID)) - ); - } - // Delete message - if (ConversationMenuItemHelper.userCanDeleteSelectedItems(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) { - items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.delete), - () -> handleActionItemClicked(Action.DELETE), - getContext().getResources().getString(R.string.AccessibilityId_delete_message) - ) - ); - } - // Ban user - if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) { - items.add(new ActionItem(R.attr.menu_block_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_user), () -> handleActionItemClicked(Action.BAN_USER))); - } - // Ban and delete all - if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) { - 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 - 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))); - } - // Resync - if (message.isSyncFailed()) { - items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resync_message), () -> handleActionItemClicked(Action.RESYNC))); - } - // Save media - if (message.isMms() && ((MediaMmsMessageRecord)message).containsMediaSlide()) { - items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD), - getContext().getResources().getString(R.string.AccessibilityId_save_attachment)) - ); - } - - backgroundView.setVisibility(View.VISIBLE); - foregroundView.setVisibility(View.VISIBLE); - - return items; - } - - private void handleActionItemClicked(@NonNull Action action) { - hideInternal(new OnHideListener() { - @Override public void startHide() { - if (onHideListener != null) { - onHideListener.startHide(); - } - } - - @Override public void onHide() { - if (onHideListener != null) { - onHideListener.onHide(); - } - - if (onActionSelectedListener != null) { - onActionSelectedListener.onActionSelected(action); - } - } - }); - } - - private void initAnimators() { - - int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration); - int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset); - - List<Animator> reveals = Stream.of(emojiViews) - .mapIndexed((idx, v) -> { - Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal); - anim.setTarget(v); - anim.setStartDelay(idx * animationEmojiStartDelayFactor); - return anim; - }) - .toList(); - - Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in); - backgroundRevealAnim.setTarget(backgroundView); - backgroundRevealAnim.setDuration(revealDuration); - backgroundRevealAnim.setStartDelay(revealOffset); - reveals.add(backgroundRevealAnim); - - revealAnimatorSet.setInterpolator(INTERPOLATOR); - revealAnimatorSet.playTogether(reveals); - } - - private @NonNull AnimatorSet newHideAnimatorSet() { - AnimatorSet set = new AnimatorSet(); - - set.addListener(new AnimationCompleteListener() { - @Override - public void onAnimationEnd(Animator animation) { - setVisibility(View.GONE); - } - }); - set.setInterpolator(INTERPOLATOR); - - set.playTogether(newHideAnimators()); - - return set; - } - - private @NonNull List<Animator> newHideAnimators() { - int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration); - - List<Animator> animators = new ArrayList<>(Stream.of(emojiViews) - .mapIndexed((idx, v) -> { - Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide); - anim.setTarget(v); - return anim; - }) - .toList()); - - Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); - backgroundHideAnim.setTarget(backgroundView); - backgroundHideAnim.setDuration(duration); - animators.add(backgroundHideAnim); - - ObjectAnimator itemScaleXAnim = new ObjectAnimator(); - itemScaleXAnim.setProperty(View.SCALE_X); - itemScaleXAnim.setFloatValues(1f); - itemScaleXAnim.setTarget(conversationItem); - itemScaleXAnim.setDuration(duration); - animators.add(itemScaleXAnim); - - ObjectAnimator itemScaleYAnim = new ObjectAnimator(); - itemScaleYAnim.setProperty(View.SCALE_Y); - itemScaleYAnim.setFloatValues(1f); - itemScaleYAnim.setTarget(conversationItem); - itemScaleYAnim.setDuration(duration); - animators.add(itemScaleYAnim); - - ObjectAnimator itemXAnim = new ObjectAnimator(); - itemXAnim.setProperty(View.X); - itemXAnim.setFloatValues(selectedConversationModel.getBubbleX()); - itemXAnim.setTarget(conversationItem); - itemXAnim.setDuration(duration); - animators.add(itemXAnim); - - ObjectAnimator itemYAnim = new ObjectAnimator(); - itemYAnim.setProperty(View.Y); - itemYAnim.setFloatValues(selectedConversationModel.getBubbleY() - statusBarHeight); - itemYAnim.setTarget(conversationItem); - itemYAnim.setDuration(duration); - animators.add(itemYAnim); - - if (activity != null) { - ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor); - statusBarAnim.setDuration(duration); - statusBarAnim.addUpdateListener(animation -> { - WindowUtil.setStatusBarColor(activity.getWindow(), (int) animation.getAnimatedValue()); - }); - animators.add(statusBarAnim); - - ValueAnimator navigationBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalNavigationBarColor); - navigationBarAnim.setDuration(duration); - navigationBarAnim.addUpdateListener(animation -> { - WindowUtil.setNavigationBarColor(activity.getWindow(), (int) animation.getAnimatedValue()); - }); - animators.add(navigationBarAnim); - } - - return animators; - } - - public interface OnHideListener { - void startHide(); - void onHide(); - } - - public interface OnReactionSelectedListener { - void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji); - void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji); - } - - public interface OnActionSelectedListener { - void onActionSelected(@NonNull Action action); - } - - private static class Boundary { - private float min; - private float max; - - Boundary() {} - - Boundary(float min, float max) { - update(min, max); - } - - private void update(float min, float max) { - this.min = min; - this.max = max; - } - - public boolean contains(float value) { - if (min < max) { - return this.min < value && this.max > value; - } else { - return this.min > value && this.max < value; - } - } - } - - private enum OverlayState { - HIDDEN, - UNINITAILIZED, - DEADZONE, - SCRUB, - TAP - } - - public enum Action { - REPLY, - RESEND, - RESYNC, - DOWNLOAD, - COPY_MESSAGE, - COPY_SESSION_ID, - VIEW_INFO, - SELECT, - DELETE, - BAN_USER, - BAN_AND_DELETE_ALL, - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt new file mode 100644 index 0000000000..405d2a3c0e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -0,0 +1,720 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.app.Activity +import android.content.Context +import android.graphics.PointF +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.util.AttributeSet +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import android.view.animation.DecelerateInterpolator +import android.view.animation.Interpolator +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.core.view.doOnLayout +import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.ThemeUtil +import org.thoughtcrime.securesms.components.emoji.EmojiImageView +import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel +import org.thoughtcrime.securesms.components.menu.ActionItem +import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanBanSelectedUsers +import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanDeleteSelectedItems +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get +import org.thoughtcrime.securesms.repository.ConversationRepository +import org.thoughtcrime.securesms.util.AnimationCompleteListener +import org.thoughtcrime.securesms.util.DateUtils +import java.util.Locale +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@AndroidEntryPoint +class ConversationReactionOverlay : FrameLayout { + private val emojiViewGlobalRect = Rect() + private val emojiStripViewBounds = Rect() + private var segmentSize = 0f + private val horizontalEmojiBoundary = Boundary() + private val verticalScrubBoundary = Boundary() + private val deadzoneTouchPoint = PointF() + private lateinit var activity: Activity + lateinit var messageRecord: MessageRecord + private lateinit var selectedConversationModel: SelectedConversationModel + private var blindedPublicKey: String? = null + private var overlayState = OverlayState.HIDDEN + private lateinit var recentEmojiPageModel: RecentEmojiPageModel + private var downIsOurs = false + private var selected = -1 + private var customEmojiIndex = 0 + private var originalStatusBarColor = 0 + private var originalNavigationBarColor = 0 + private lateinit var dropdownAnchor: View + private lateinit var conversationItem: LinearLayout + private lateinit var conversationBubble: View + private lateinit var conversationTimestamp: TextView + private lateinit var backgroundView: View + private lateinit var foregroundView: ConstraintLayout + private lateinit var emojiViews: List<EmojiImageView> + private var contextMenu: ConversationContextMenu? = null + private var touchDownDeadZoneSize = 0f + private var distanceFromTouchDownPointToBottomOfScrubberDeadZone = 0f + private var scrubberWidth = 0 + private var selectedVerticalTranslation = 0 + private var scrubberHorizontalMargin = 0 + private var animationEmojiStartDelayFactor = 0 + private var statusBarHeight = 0 + private var onReactionSelectedListener: OnReactionSelectedListener? = null + private var onActionSelectedListener: OnActionSelectedListener? = null + private var onHideListener: OnHideListener? = null + private val revealAnimatorSet = AnimatorSet() + private var hideAnimatorSet = AnimatorSet() + + @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase + @Inject lateinit var repository: ConversationRepository + private val scope = CoroutineScope(Dispatchers.Default) + private var job: Job? = null + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + override fun onFinishInflate() { + super.onFinishInflate() + dropdownAnchor = findViewById(R.id.dropdown_anchor) + conversationItem = findViewById(R.id.conversation_item) + conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble) + conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp) + backgroundView = findViewById(R.id.conversation_reaction_scrubber_background) + foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground) + emojiViews = listOf(R.id.reaction_1, R.id.reaction_2, R.id.reaction_3, R.id.reaction_4, R.id.reaction_5, R.id.reaction_6, R.id.reaction_7).map { findViewById(it) } + customEmojiIndex = emojiViews.size - 1 + distanceFromTouchDownPointToBottomOfScrubberDeadZone = resources.getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom).toFloat() + touchDownDeadZoneSize = resources.getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size).toFloat() + scrubberWidth = resources.getDimensionPixelOffset(R.dimen.reaction_scrubber_width) + selectedVerticalTranslation = resources.getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation) + scrubberHorizontalMargin = resources.getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin) + animationEmojiStartDelayFactor = resources.getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor) + initAnimators() + } + + fun show(activity: Activity, + messageRecord: MessageRecord, + lastSeenDownPoint: PointF, + selectedConversationModel: SelectedConversationModel, + blindedPublicKey: String?) { + job?.cancel() + if (overlayState != OverlayState.HIDDEN) return + this.messageRecord = messageRecord + this.selectedConversationModel = selectedConversationModel + this.blindedPublicKey = blindedPublicKey + overlayState = OverlayState.UNINITAILIZED + selected = -1 + recentEmojiPageModel = RecentEmojiPageModel(activity) + setupSelectedEmoji() + val statusBarBackground = activity.findViewById<View>(android.R.id.statusBarBackground) + statusBarHeight = statusBarBackground?.height ?: 0 + val conversationItemSnapshot = selectedConversationModel.bitmap + conversationBubble.layoutParams = LinearLayout.LayoutParams(conversationItemSnapshot.width, conversationItemSnapshot.height) + conversationBubble.background = BitmapDrawable(resources, conversationItemSnapshot) + conversationTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), messageRecord.timestamp) + updateConversationTimestamp(messageRecord) + val isMessageOnLeft = selectedConversationModel.isOutgoing xor ViewUtil.isLtr(this) + conversationItem.scaleX = LONG_PRESS_SCALE_FACTOR + conversationItem.scaleY = LONG_PRESS_SCALE_FACTOR + visibility = INVISIBLE + this.activity = activity + updateSystemUiOnShow(activity) + doOnLayout { showAfterLayout(messageRecord, lastSeenDownPoint, isMessageOnLeft) } + + job = scope.launch(Dispatchers.IO) { + repository.changes(messageRecord.threadId) + .filter { mmsSmsDatabase.getMessageForTimestamp(messageRecord.timestamp) == null } + .collect { withContext(Dispatchers.Main) { hide() } } + } + } + + private fun updateConversationTimestamp(message: MessageRecord) { + if (message.isOutgoing) conversationBubble.bringToFront() else conversationTimestamp.bringToFront() + } + + private fun showAfterLayout(messageRecord: MessageRecord, + lastSeenDownPoint: PointF, + isMessageOnLeft: Boolean) { + val contextMenu = ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord)) + this.contextMenu = contextMenu + var endX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX - conversationItem.width + selectedConversationModel.bubbleWidth + var endY = selectedConversationModel.bubbleY - statusBarHeight + conversationItem.x = endX + conversationItem.y = endY + val conversationItemSnapshot = selectedConversationModel.bitmap + val isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < width + val overlayHeight = height + val bubbleWidth = selectedConversationModel.bubbleWidth + var endApparentTop = endY + var endScale = 1f + val menuPadding = DimensionUnit.DP.toPixels(12f) + val reactionBarTopPadding = DimensionUnit.DP.toPixels(32f) + val reactionBarHeight = backgroundView.height + var reactionBarBackgroundY: Float + if (isWideLayout) { + val everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.height < overlayHeight + if (everythingFitsVertically) { + val reactionBarFitsAboveItem = conversationItem.y > reactionBarHeight + menuPadding + reactionBarTopPadding + if (reactionBarFitsAboveItem) { + reactionBarBackgroundY = conversationItem.y - menuPadding - reactionBarHeight + } else { + endY = reactionBarHeight + menuPadding + reactionBarTopPadding + reactionBarBackgroundY = reactionBarTopPadding + } + } else { + val spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding + endScale = spaceAvailableForItem / conversationItem.height + endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1 + endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) + reactionBarBackgroundY = reactionBarTopPadding + } + } else { + val reactionBarOffset = DimensionUnit.DP.toPixels(48f) + val spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset, 0f) + val everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.height + menuPadding + spaceForReactionBar < overlayHeight + if (everythingFitsVertically) { + val bubbleBottom = selectedConversationModel.bubbleY + conversationItemSnapshot.height + val menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight + if (menuFitsBelowItem) { + if (conversationItem.y < 0) { + endY = 0f + } + val contextMenuTop = endY + conversationItemSnapshot.height + reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.bubbleY, contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY) + if (reactionBarBackgroundY <= reactionBarTopPadding) { + endY = backgroundView.height + menuPadding + reactionBarTopPadding + } + } else { + endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.height + reactionBarBackgroundY = endY - reactionBarHeight - menuPadding + } + endApparentTop = endY + } else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) { + val spaceAvailableForItem = overlayHeight.toFloat() - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar + endScale = spaceAvailableForItem / conversationItemSnapshot.height + endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1 + endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) + val contextMenuTop = endY + conversationItemSnapshot.height * endScale + reactionBarBackgroundY = reactionBarTopPadding //getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); + endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) + } else { + contextMenu.height = contextMenu.getMaxHeight() / 2 + val menuHeight = contextMenu.height + val fitsVertically = menuHeight + conversationItem.height + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight + if (fitsVertically) { + val bubbleBottom = selectedConversationModel.bubbleY + conversationItemSnapshot.height + val menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight + if (menuFitsBelowItem) { + reactionBarBackgroundY = conversationItem.y - menuPadding - reactionBarHeight + if (reactionBarBackgroundY < reactionBarTopPadding) { + endY = reactionBarTopPadding + reactionBarHeight + menuPadding + reactionBarBackgroundY = reactionBarTopPadding + } + } else { + endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.height + reactionBarBackgroundY = endY - reactionBarHeight - menuPadding + } + endApparentTop = endY + } else { + val spaceAvailableForItem = overlayHeight.toFloat() - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding + endScale = spaceAvailableForItem / conversationItemSnapshot.height + endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1 + endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) + menuPadding + reactionBarTopPadding + reactionBarBackgroundY = reactionBarTopPadding + endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding + } + } + } + reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight.toFloat()) + hideAnimatorSet.end() + visibility = VISIBLE + val scrubberX = if (isMessageOnLeft) { + scrubberHorizontalMargin.toFloat() + } else { + (width - scrubberWidth - scrubberHorizontalMargin).toFloat() + } + foregroundView.x = scrubberX + foregroundView.y = reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.height / 2f + backgroundView.x = scrubberX + backgroundView.y = reactionBarBackgroundY + verticalScrubBoundary.update(reactionBarBackgroundY, + lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone) + updateBoundsOnLayoutChanged() + revealAnimatorSet.start() + if (isWideLayout) { + val scrubberRight = scrubberX + scrubberWidth + val offsetX = if (isMessageOnLeft) scrubberRight + menuPadding else scrubberX - contextMenu.getMaxWidth() - menuPadding + contextMenu.show(offsetX.toInt(), Math.min(backgroundView.y, (overlayHeight - contextMenu.getMaxHeight()).toFloat()).toInt()) + } else { + val contentX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX + val offsetX = if (isMessageOnLeft) contentX else -contextMenu.getMaxWidth() + contentX + bubbleWidth + val menuTop = endApparentTop + conversationItemSnapshot.height * endScale + contextMenu.show(offsetX.toInt(), (menuTop + menuPadding).toInt()) + } + val revealDuration = context.resources.getInteger(R.integer.reaction_scrubber_reveal_duration) + conversationBubble.animate() + .scaleX(endScale) + .scaleY(endScale) + .setDuration(revealDuration.toLong()) + conversationItem.animate() + .x(endX) + .y(endY) + .setDuration(revealDuration.toLong()) + } + + private fun getReactionBarOffsetForTouch(itemY: Float, + contextMenuTop: Float, + contextMenuPadding: Float, + reactionBarOffset: Float, + reactionBarHeight: Int, + spaceNeededBetweenTopOfScreenAndTopOfReactionBar: Float, + messageTop: Float): Float { + val adjustedTouchY = itemY - statusBarHeight + var reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop) + val spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop) + if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150f)) { + val offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding + reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding + } + return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar) + } + + private fun updateSystemUiOnShow(activity: Activity) { + val window = activity.window + val barColor = ContextCompat.getColor(context, R.color.reactions_screen_dark_shade_color) + originalStatusBarColor = window.statusBarColor + WindowUtil.setStatusBarColor(window, barColor) + originalNavigationBarColor = window.navigationBarColor + WindowUtil.setNavigationBarColor(window, barColor) + if (!ThemeUtil.isDarkTheme(context)) { + WindowUtil.clearLightStatusBar(window) + WindowUtil.clearLightNavigationBar(window) + } + } + + fun hide() { + hideInternal(onHideListener) + } + + fun hideForReactWithAny() { + hideInternal(onHideListener) + } + + private fun hideInternal(onHideListener: OnHideListener?) { + job?.cancel() + overlayState = OverlayState.HIDDEN + val animatorSet = newHideAnimatorSet() + hideAnimatorSet = animatorSet + revealAnimatorSet.end() + animatorSet.start() + onHideListener?.startHide() + selectedConversationModel.focusedView?.let(ViewUtil::focusAndShowKeyboard) + animatorSet.addListener(object : AnimationCompleteListener() { + override fun onAnimationEnd(animation: Animator) { + animatorSet.removeListener(this) + onHideListener?.onHide() + } + }) + contextMenu?.dismiss() + } + + val isShowing: Boolean + get() = overlayState != OverlayState.HIDDEN + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + updateBoundsOnLayoutChanged() + } + + private fun updateBoundsOnLayoutChanged() { + backgroundView.getGlobalVisibleRect(emojiStripViewBounds) + emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect) + emojiStripViewBounds.left = getStart(emojiViewGlobalRect) + emojiViews[emojiViews.size - 1].getGlobalVisibleRect(emojiViewGlobalRect) + emojiStripViewBounds.right = getEnd(emojiViewGlobalRect) + segmentSize = emojiStripViewBounds.width() / emojiViews.size.toFloat() + } + + private fun getStart(rect: Rect): Int = if (ViewUtil.isLtr(this)) rect.left else rect.right + + private fun getEnd(rect: Rect): Int = if (ViewUtil.isLtr(this)) rect.right else rect.left + + fun applyTouchEvent(motionEvent: MotionEvent): Boolean { + check(isShowing) { "Touch events should only be propagated to this method if we are displaying the scrubber." } + if (motionEvent.action and MotionEvent.ACTION_POINTER_INDEX_MASK != 0) { + return true + } + if (overlayState == OverlayState.UNINITAILIZED) { + downIsOurs = false + deadzoneTouchPoint[motionEvent.x] = motionEvent.y + overlayState = OverlayState.DEADZONE + } + if (overlayState == OverlayState.DEADZONE) { + val deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.x) + val deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.y) + if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) { + overlayState = OverlayState.SCRUB + } else { + if (motionEvent.action == MotionEvent.ACTION_UP) { + overlayState = OverlayState.TAP + if (downIsOurs) { + handleUpEvent() + return true + } + } + return MotionEvent.ACTION_MOVE == motionEvent.action + } + } + return when (motionEvent.action) { + MotionEvent.ACTION_DOWN -> { + selected = getSelectedIndexViaDownEvent(motionEvent) + deadzoneTouchPoint[motionEvent.x] = motionEvent.y + overlayState = OverlayState.DEADZONE + downIsOurs = true + true + } + + MotionEvent.ACTION_MOVE -> { + selected = getSelectedIndexViaMoveEvent(motionEvent) + true + } + + MotionEvent.ACTION_UP -> { + handleUpEvent() + downIsOurs + } + + MotionEvent.ACTION_CANCEL -> { + hide() + downIsOurs + } + + else -> false + } + } + + private fun setupSelectedEmoji() { + val emojis = recentEmojiPageModel.emoji + emojiViews.forEachIndexed { i, view -> + view.scaleX = 1.0f + view.scaleY = 1.0f + view.translationY = 0f + val isAtCustomIndex = i == customEmojiIndex + if (isAtCustomIndex) { + view.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_baseline_add_24)) + view.tag = null + } else { + view.setImageEmoji(emojis[i]) + } + } + } + + private fun getSelectedIndexViaDownEvent(motionEvent: MotionEvent): Int = + getSelectedIndexViaMotionEvent(motionEvent, Boundary(emojiStripViewBounds.top.toFloat(), emojiStripViewBounds.bottom.toFloat())) + + private fun getSelectedIndexViaMoveEvent(motionEvent: MotionEvent): Int = + getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary) + + private fun getSelectedIndexViaMotionEvent(motionEvent: MotionEvent, boundary: Boundary): Int { + var selected = -1 + if (backgroundView.visibility != VISIBLE) { + return selected + } + for (i in emojiViews.indices) { + val emojiLeft = segmentSize * i + emojiStripViewBounds.left + horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize) + if (horizontalEmojiBoundary.contains(motionEvent.x) && boundary.contains(motionEvent.y)) { + selected = i + } + } + if (this.selected != -1 && this.selected != selected) { + shrinkView(emojiViews[this.selected]) + } + if (this.selected != selected && selected != -1) { + growView(emojiViews[selected]) + } + return selected + } + + private fun growView(view: View) { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + view.animate() + .scaleY(1.5f) + .scaleX(1.5f) + .translationY(-selectedVerticalTranslation.toFloat()) + .setDuration(200) + .setInterpolator(INTERPOLATOR) + .start() + } + + private fun shrinkView(view: View) { + view.animate() + .scaleX(1.0f) + .scaleY(1.0f) + .translationY(0f) + .setDuration(200) + .setInterpolator(INTERPOLATOR) + .start() + } + + private fun handleUpEvent() { + val onReactionSelectedListener = onReactionSelectedListener + if (selected != -1 && onReactionSelectedListener != null && backgroundView.visibility == VISIBLE) { + if (selected == customEmojiIndex) { + onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].tag != null) + } else { + onReactionSelectedListener.onReactionSelected(messageRecord, recentEmojiPageModel.emoji[selected]) + } + } else { + hide() + } + } + + fun setOnReactionSelectedListener(onReactionSelectedListener: OnReactionSelectedListener?) { + this.onReactionSelectedListener = onReactionSelectedListener + } + + fun setOnActionSelectedListener(onActionSelectedListener: OnActionSelectedListener?) { + this.onActionSelectedListener = onActionSelectedListener + } + + fun setOnHideListener(onHideListener: OnHideListener?) { + this.onHideListener = onHideListener + } + + private fun getOldEmoji(messageRecord: MessageRecord): String? = + messageRecord.reactions + .filter { it.author == getLocalNumber(context) } + .firstOrNull() + ?.let(ReactionRecord::emoji) + + private fun getMenuActionItems(message: MessageRecord): List<ActionItem> { + val items: MutableList<ActionItem> = ArrayList() + + // Prepare + val containsControlMessage = message.isUpdate + val hasText = !message.body.isEmpty() + val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(message.threadId) + val recipient = get(context).threadDatabase().getRecipientForThreadId(message.threadId) + ?: return emptyList() + val userPublicKey = getLocalNumber(context)!! + // Select message + items += ActionItem(R.attr.menu_select_icon, R.string.conversation_context__menu_select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select) + // Reply + val canWrite = openGroup == null || openGroup.canWrite + if (canWrite && !message.isPending && !message.isFailed) { + items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply_message) + } + // Copy message text + if (!containsControlMessage && hasText) { + items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) }) + } + // Copy Session ID + if (recipient.isGroupRecipient && !recipient.isOpenGroupRecipient && message.recipient.address.toString() != userPublicKey) { + items += ActionItem(R.attr.menu_copy_icon, R.string.activity_conversation_menu_copy_session_id, { handleActionItemClicked(Action.COPY_SESSION_ID) }) + } + // Delete message + if (userCanDeleteSelectedItems(context, message, openGroup, userPublicKey, blindedPublicKey)) { + items += ActionItem(R.attr.menu_trash_icon, R.string.delete, { handleActionItemClicked(Action.DELETE) }, R.string.AccessibilityId_delete_message, message.subtitle, R.color.destructive) + } + // Ban user + if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) { + items += ActionItem(R.attr.menu_block_icon, R.string.conversation_context__menu_ban_user, { handleActionItemClicked(Action.BAN_USER) }) + } + // Ban and delete all + if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) { + items += ActionItem(R.attr.menu_trash_icon, R.string.conversation_context__menu_ban_and_delete_all, { handleActionItemClicked(Action.BAN_AND_DELETE_ALL) }) + } + // Message detail + items += ActionItem(R.attr.menu_info_icon, R.string.conversation_context__menu_message_details, { handleActionItemClicked(Action.VIEW_INFO) }) + // Resend + if (message.isFailed) { + items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_resend_message, { handleActionItemClicked(Action.RESEND) }) + } + // Resync + if (message.isSyncFailed) { + items += ActionItem(R.attr.menu_reply_icon, R.string.conversation_context__menu_resync_message, { handleActionItemClicked(Action.RESYNC) }) + } + // Save media + if (message.isMms && (message as MediaMmsMessageRecord).containsMediaSlide()) { + items += ActionItem(R.attr.menu_save_icon, R.string.conversation_context_image__save_attachment, { handleActionItemClicked(Action.DOWNLOAD) }, R.string.AccessibilityId_save_attachment) + } + backgroundView.visibility = VISIBLE + foregroundView.visibility = VISIBLE + return items + } + + private fun handleActionItemClicked(action: Action) { + hideInternal(object : OnHideListener { + override fun startHide() { + onHideListener?.startHide() + } + + override fun onHide() { + onHideListener?.onHide() + onActionSelectedListener?.onActionSelected(action) + } + }) + } + + private fun initAnimators() { + val revealDuration = context.resources.getInteger(R.integer.reaction_scrubber_reveal_duration) + val revealOffset = context.resources.getInteger(R.integer.reaction_scrubber_reveal_offset) + val reveals = emojiViews.mapIndexed { idx: Int, v: EmojiImageView? -> + AnimatorInflaterCompat.loadAnimator(context, R.animator.reactions_scrubber_reveal).apply { + setTarget(v) + startDelay = (idx * animationEmojiStartDelayFactor).toLong() + } + } + AnimatorInflaterCompat.loadAnimator(context, android.R.animator.fade_in).apply { + setTarget(backgroundView) + setDuration(revealDuration.toLong()) + startDelay = revealOffset.toLong() + } + revealAnimatorSet.interpolator = INTERPOLATOR + revealAnimatorSet.playTogether(reveals) + } + + private fun newHideAnimatorSet() = AnimatorSet().apply { + addListener(object : AnimationCompleteListener() { + override fun onAnimationEnd(animation: Animator) { + visibility = GONE + } + }) + interpolator = INTERPOLATOR + playTogether(newHideAnimators()) + } + + private fun newHideAnimators(): List<Animator> { + val duration = context.resources.getInteger(R.integer.reaction_scrubber_hide_duration).toLong() + fun conversationItemAnimator(configure: ObjectAnimator.() -> Unit) = ObjectAnimator().apply { + target = conversationItem + setDuration(duration) + configure() + } + return emojiViews.map { + AnimatorInflaterCompat.loadAnimator(context, R.animator.reactions_scrubber_hide).apply { setTarget(it) } + } + AnimatorInflaterCompat.loadAnimator(context, android.R.animator.fade_out).apply { + setTarget(backgroundView) + setDuration(duration) + } + conversationItemAnimator { + setProperty(SCALE_X) + setFloatValues(1f) + } + conversationItemAnimator { + setProperty(SCALE_Y) + setFloatValues(1f) + } + conversationItemAnimator { + setProperty(X) + setFloatValues(selectedConversationModel.bubbleX) + } + conversationItemAnimator { + setProperty(Y) + setFloatValues(selectedConversationModel.bubbleY - statusBarHeight) + } + ValueAnimator.ofArgb(activity.window.statusBarColor, originalStatusBarColor).apply { + setDuration(duration) + addUpdateListener { animation: ValueAnimator -> WindowUtil.setStatusBarColor(activity.window, animation.animatedValue as Int) } + } + ValueAnimator.ofArgb(activity.window.statusBarColor, originalNavigationBarColor).apply { + setDuration(duration) + addUpdateListener { animation: ValueAnimator -> WindowUtil.setNavigationBarColor(activity.window, animation.animatedValue as Int) } + } + } + + interface OnHideListener { + fun startHide() + fun onHide() + } + + interface OnReactionSelectedListener { + fun onReactionSelected(messageRecord: MessageRecord, emoji: String) + fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) + } + + interface OnActionSelectedListener { + fun onActionSelected(action: Action) + } + + private class Boundary(private var min: Float = 0f, private var max: Float = 0f) { + + fun update(min: Float, max: Float) { + this.min = min + this.max = max + } + + operator fun contains(value: Float) = if (min < max) { + min < value && max > value + } else { + min > value && max < value + } + } + + private enum class OverlayState { + HIDDEN, + UNINITAILIZED, + DEADZONE, + SCRUB, + TAP + } + + enum class Action { + REPLY, + RESEND, + RESYNC, + DOWNLOAD, + COPY_MESSAGE, + COPY_SESSION_ID, + VIEW_INFO, + SELECT, + DELETE, + BAN_USER, + BAN_AND_DELETE_ALL + } + + companion object { + const val LONG_PRESS_SCALE_FACTOR = 0.95f + private val INTERPOLATOR: Interpolator = DecelerateInterpolator() + } +} + +private fun Duration.to2partString(): String? = + toComponents { days, hours, minutes, seconds, nanoseconds -> listOf(days.days, hours.hours, minutes.minutes, seconds.seconds) } + .filter { it.inWholeSeconds > 0L }.take(2).takeIf { it.isNotEmpty() }?.joinToString(" ") + +private val MessageRecord.subtitle: ((Context) -> CharSequence?)? + get() = if (expiresIn <= 0) { + null + } else { context -> + (expiresIn - (SnodeAPI.nowWithOffset - (expireStarted.takeIf { it > 0 } ?: timestamp))) + .coerceAtLeast(0L) + .milliseconds + .to2partString() + ?.let { context.getString(R.string.auto_deletes_in, it) } + } \ No newline at end of file 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 532e65e196..677dd22f60 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,5 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2 +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -12,10 +13,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.open_groups.OpenGroup 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.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log @@ -40,6 +43,9 @@ class ConversationViewModel( private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce { repository.maybeGetRecipientForThreadId(threadId) } + val expirationConfiguration: ExpirationConfiguration? + get() = storage.getExpirationConfiguration(threadId) + val recipient: Recipient? get() = _recipient.value @@ -215,8 +221,11 @@ class ConversationViewModel( } fun hidesInputBar(): Boolean = openGroup?.canWrite != true && - blindedRecipient?.blocksCommunityMessageRequests == true + blindedRecipient?.blocksCommunityMessageRequests == true + fun legacyBannerRecipient(context: Context): Recipient? = recipient?.run { + storage.getLastLegacyRecipient(address.serialize())?.let { Recipient.from(context, Address.fromSerialized(it), false) } + } @dagger.assisted.AssistedFactory interface AssistedFactory { 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 index a73fe41139..4ebc1f27b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt @@ -5,9 +5,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import network.loki.messenger.R @@ -26,6 +28,7 @@ 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.repository.ConversationRepository import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.TitledText import java.util.Date @@ -38,8 +41,11 @@ class MessageDetailsViewModel @Inject constructor( private val lokiMessageDatabase: LokiMessageDatabase, private val mmsSmsDatabase: MmsSmsDatabase, private val threadDb: ThreadDatabase, + private val repository: ConversationRepository, ) : ViewModel() { + private var job: Job? = null + private val state = MutableStateFlow(MessageDetailsState()) val stateFlow = state.asStateFlow() @@ -48,6 +54,8 @@ class MessageDetailsViewModel @Inject constructor( var timestamp: Long = 0L set(value) { + job?.cancel() + field = value val record = mmsSmsDatabase.getMessageForTimestamp(timestamp) @@ -58,6 +66,12 @@ class MessageDetailsViewModel @Inject constructor( val mmsRecord = record as? MmsMessageRecord + job = viewModelScope.launch { + repository.changes(record.threadId) + .filter { mmsSmsDatabase.getMessageForTimestamp(value) == null } + .collect { event.send(Event.Finish) } + } + state.value = record.run { val slides = mmsRecord?.slideDeck?.slides ?: emptyList() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt index 330534e232..d76e6f2b3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/AlbumThumbnailView.kt @@ -7,7 +7,6 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.view.MotionEvent import android.view.ViewGroup -import android.widget.FrameLayout import android.widget.RelativeLayout import android.widget.TextView import androidx.core.view.children @@ -41,7 +40,7 @@ class AlbumThumbnailView : RelativeLayout { private var slides: List<Slide> = listOf() private var slideSize: Int = 0 - override fun dispatchDraw(canvas: Canvas?) { + override fun dispatchDraw(canvas: Canvas) { super.dispatchDraw(canvas) cornerMask.mask(canvas) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.java deleted file mode 100644 index 6765232c77..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.java +++ /dev/null @@ -1,129 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.components; - -import android.content.Context; -import android.util.AttributeSet; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.session.libsession.utilities.Util; - -import java.lang.ref.WeakReference; -import java.util.concurrent.TimeUnit; - -import network.loki.messenger.R; - -public class ExpirationTimerView extends androidx.appcompat.widget.AppCompatImageView { - - private long startedAt; - private long expiresIn; - - private boolean visible = false; - private boolean stopped = true; - - private final int[] frames = new int[]{ R.drawable.timer00, - R.drawable.timer05, - R.drawable.timer10, - R.drawable.timer15, - R.drawable.timer20, - R.drawable.timer25, - R.drawable.timer30, - R.drawable.timer35, - R.drawable.timer40, - R.drawable.timer45, - R.drawable.timer50, - R.drawable.timer55, - R.drawable.timer60 }; - - public ExpirationTimerView(Context context) { - super(context); - } - - public ExpirationTimerView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - public ExpirationTimerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public void setExpirationTime(long startedAt, long expiresIn) { - this.startedAt = startedAt; - this.expiresIn = expiresIn; - setPercentComplete(calculateProgress(this.startedAt, this.expiresIn)); - } - - public void setPercentComplete(float percentage) { - float percentFull = 1 - percentage; - int frame = (int) Math.ceil(percentFull * (frames.length - 1)); - - frame = Math.max(0, Math.min(frame, frames.length - 1)); - setImageResource(frames[frame]); - } - - public void startAnimation() { - synchronized (this) { - visible = true; - if (!stopped) return; - else stopped = false; - } - - Util.runOnMainDelayed(new AnimationUpdateRunnable(this), calculateAnimationDelay(this.startedAt, this.expiresIn)); - } - - public void stopAnimation() { - synchronized (this) { - visible = false; - } - } - - private float calculateProgress(long startedAt, long expiresIn) { - long progressed = System.currentTimeMillis() - startedAt; - float percentComplete = (float)progressed / (float)expiresIn; - - return Math.max(0, Math.min(percentComplete, 1)); - } - - private long calculateAnimationDelay(long startedAt, long expiresIn) { - long progressed = System.currentTimeMillis() - startedAt; - long remaining = expiresIn - progressed; - - if (remaining <= 0) { - return 0; - } else if (remaining < TimeUnit.SECONDS.toMillis(30)) { - return 1000; - } else { - return 5000; - } - } - - private static class AnimationUpdateRunnable implements Runnable { - - private final WeakReference<ExpirationTimerView> expirationTimerViewReference; - - private AnimationUpdateRunnable(@NonNull ExpirationTimerView expirationTimerView) { - this.expirationTimerViewReference = new WeakReference<>(expirationTimerView); - } - - @Override - public void run() { - ExpirationTimerView timerView = expirationTimerViewReference.get(); - if (timerView == null) return; - - long nextUpdate = timerView.calculateAnimationDelay(timerView.startedAt, timerView.expiresIn); - synchronized (timerView) { - if (timerView.visible) { - timerView.setExpirationTime(timerView.startedAt, timerView.expiresIn); - } else { - timerView.stopped = true; - return; - } - if (nextUpdate <= 0) { - timerView.stopped = true; - return; - } - } - Util.runOnMainDelayed(this, nextUpdate); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt new file mode 100644 index 0000000000..d173dacfef --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.conversation.v2.components + +import android.content.Context +import android.graphics.drawable.AnimationDrawable +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.content.ContextCompat +import network.loki.messenger.R +import org.session.libsession.snode.SnodeAPI.nowWithOffset +import kotlin.math.round + +class ExpirationTimerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatImageView(context, attrs, defStyleAttr) { + private val frames = intArrayOf( + R.drawable.timer00, + R.drawable.timer05, + R.drawable.timer10, + R.drawable.timer15, + R.drawable.timer20, + R.drawable.timer25, + R.drawable.timer30, + R.drawable.timer35, + R.drawable.timer40, + R.drawable.timer45, + R.drawable.timer50, + R.drawable.timer55, + R.drawable.timer60 + ) + + fun setTimerIcon() { + setExpirationTime(0L, 0L) + } + + fun setExpirationTime(startedAt: Long, expiresIn: Long) { + if (expiresIn == 0L) { + setImageResource(R.drawable.timer55) + return + } + + if (startedAt == 0L) { + // timer has not started + setImageResource(R.drawable.timer60) + return + } + + val elapsedTime = nowWithOffset - startedAt + val remainingTime = expiresIn - elapsedTime + val remainingPercent = (remainingTime / expiresIn.toFloat()).coerceIn(0f, 1f) + + val frameCount = round(frames.size * remainingPercent).toInt().coerceIn(1, frames.size) + val frameTime = round(remainingTime / frameCount.toFloat()).toInt() + + AnimationDrawable().apply { + frames.take(frameCount).reversed().forEach { addFrame(ContextCompat.getDrawable(context, it)!!, frameTime) } + isOneShot = true + }.also(::setImageDrawable).apply(AnimationDrawable::start) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index 73e2d571c0..5959c41d16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -37,6 +37,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li private val vMargin by lazy { toDp(4, resources) } private val minHeight by lazy { toPx(56, resources) } private var linkPreviewDraftView: LinkPreviewDraftView? = null + private var quoteView: QuoteView? = null var delegate: InputBarDelegate? = null var additionalContentHeight = 0 var quote: MessageRecord? = null @@ -98,7 +99,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE binding.inputBarEditText.inputType = binding.inputBarEditText.inputType or - InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + InputType.TYPE_TEXT_FLAG_CAP_SENTENCES } val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0 binding.inputBarEditText.imeOptions = binding.inputBarEditText.imeOptions or incognitoFlag // Always use incognito keyboard if setting enabled @@ -138,53 +139,64 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li delegate?.startRecordingVoiceMessage() } - // Drafting quotes and drafting link previews is mutually exclusive, i.e. you can't draft - // a quote and a link preview at the same time. - fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) { quote = message - linkPreview = null - linkPreviewDraftView = null - binding.inputBarAdditionalContentContainer.removeAllViews() - // inflate quoteview with typed array here + // If we already have a link preview View then clear the 'additional content' layout so that + // our quote View is always the first element (i.e., at the top of the reply). + if (linkPreview != null && linkPreviewDraftView != null) { + binding.inputBarAdditionalContentContainer.removeAllViews() + } + + // Inflate quote View with typed array here val layout = LayoutInflater.from(context).inflate(R.layout.view_quote_draft, binding.inputBarAdditionalContentContainer, false) - val quoteView = layout.findViewById<QuoteView>(R.id.mainQuoteViewContainer) - quoteView.delegate = this - binding.inputBarAdditionalContentContainer.addView(layout) - val attachments = (message as? MmsMessageRecord)?.slideDeck - val sender = if (message.isOutgoing) TextSecurePreferences.getLocalNumber(context)!! else message.individualRecipient.address.serialize() - quoteView.bind(sender, message.body, attachments, - thread, true, message.isOpenGroupInvitation, message.threadId, false, glide) + quoteView = layout.findViewById<QuoteView>(R.id.mainQuoteViewContainer).also { + it.delegate = this + binding.inputBarAdditionalContentContainer.addView(layout) + val attachments = (message as? MmsMessageRecord)?.slideDeck + val sender = if (message.isOutgoing) TextSecurePreferences.getLocalNumber(context)!! else message.individualRecipient.address.serialize() + it.bind(sender, message.body, attachments, thread, true, message.isOpenGroupInvitation, message.threadId, false, glide) + } + + // Before we request a layout update we'll add back any LinkPreviewDraftView that might + // exist - as this goes into the LinearLayout second it will be below the quote View. + if (linkPreview != null && linkPreviewDraftView != null) { + binding.inputBarAdditionalContentContainer.addView(linkPreviewDraftView) + } requestLayout() } override fun cancelQuoteDraft() { + binding.inputBarAdditionalContentContainer.removeView(quoteView) quote = null - binding.inputBarAdditionalContentContainer.removeAllViews() + quoteView = null requestLayout() } fun draftLinkPreview() { - quote = null - binding.inputBarAdditionalContentContainer.removeAllViews() - val linkPreviewDraftView = LinkPreviewDraftView(context) - linkPreviewDraftView.delegate = this - this.linkPreviewDraftView = linkPreviewDraftView + // As `draftLinkPreview` is called before `updateLinkPreview` when we modify a URI in a + // message we'll bail early if a link preview View already exists and just let + // `updateLinkPreview` get called to update the existing View. + if (linkPreview != null && linkPreviewDraftView != null) return + + linkPreviewDraftView = LinkPreviewDraftView(context).also { it.delegate = this } + + // Add the link preview View. Note: If there's already a quote View in the 'additional + // content' container then this preview View will be added after / below it - which is fine. binding.inputBarAdditionalContentContainer.addView(linkPreviewDraftView) requestLayout() } - fun updateLinkPreviewDraft(glide: GlideRequests, linkPreview: LinkPreview) { - this.linkPreview = linkPreview - val linkPreviewDraftView = this.linkPreviewDraftView ?: return - linkPreviewDraftView.update(glide, linkPreview) + fun updateLinkPreviewDraft(glide: GlideRequests, updatedLinkPreview: LinkPreview) { + // Update our `linkPreview` property with the new (provided as an argument to this function) + // then update the View from that. + linkPreview = updatedLinkPreview.also { linkPreviewDraftView?.update(glide, it) } } override fun cancelLinkPreviewDraft() { - if (quote != null) { return } + binding.inputBarAdditionalContentContainer.removeView(linkPreviewDraftView) linkPreview = null - binding.inputBarAdditionalContentContainer.removeAllViews() + linkPreviewDraftView = null requestLayout() } 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 02ee4ae45f..dadf138ead 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 @@ -4,16 +4,11 @@ import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.graphics.BitmapFactory -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter import android.os.AsyncTask import android.view.Menu import android.view.MenuInflater import android.view.MenuItem -import android.widget.ImageView -import android.widget.TextView import android.widget.Toast -import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.widget.SearchView @@ -24,10 +19,8 @@ import androidx.core.graphics.drawable.IconCompat import network.loki.messenger.R import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.leave -import org.session.libsession.utilities.ExpirationUtil import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.toHexString @@ -42,8 +35,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.showSessionDialog import org.thoughtcrime.securesms.util.BitmapUtil import java.io.IOException @@ -53,9 +46,7 @@ object ConversationMenuHelper { menu: Menu, inflater: MenuInflater, thread: Recipient, - threadId: Long, - context: Context, - onOptionsItemSelected: (MenuItem) -> Unit + context: Context ) { // Prepare menu.clear() @@ -63,21 +54,8 @@ 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) && !thread.isBlocked) { - if (thread.expireMessages > 0) { - inflater.inflate(R.menu.menu_conversation_expiration_on, menu) - val item = menu.findItem(R.id.menu_expiring_messages) - item.actionView?.let { actionView -> - val iconView = actionView.findViewById<ImageView>(R.id.menu_badge_icon) - val badgeView = actionView.findViewById<TextView>(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) - } + if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) { + inflater.inflate(R.menu.menu_conversation_expiration, menu) } // One-on-one chat menu allows copying the session id if (thread.isContactRecipient) { @@ -110,7 +88,7 @@ object ConversationMenuHelper { inflater.inflate(R.menu.menu_conversation_notification_settings, menu) } - if (!thread.isGroupRecipient && thread.hasApprovedMe()) { + if (thread.showCallMenu()) { inflater.inflate(R.menu.menu_conversation_call, menu) } @@ -153,8 +131,7 @@ object ConversationMenuHelper { R.id.menu_view_all_media -> { showAllMedia(context, thread) } R.id.menu_search -> { search(context) } R.id.menu_add_shortcut -> { addShortcut(context, thread) } - R.id.menu_expiring_messages -> { showExpiringMessagesDialog(context, thread) } - R.id.menu_expiring_messages_off -> { showExpiringMessagesDialog(context, thread) } + R.id.menu_expiring_messages -> { showDisappearingMessages(context, thread) } R.id.menu_unblock -> { unblock(context, thread) } R.id.menu_block -> { block(context, thread, deleteThread = false) } R.id.menu_block_delete -> { blockAndDelete(context, thread) } @@ -210,6 +187,7 @@ object ConversationMenuHelper { private fun addShortcut(context: Context, thread: Recipient) { object : AsyncTask<Void?, Void?, IconCompat?>() { + @Deprecated("Deprecated in Java") override fun doInBackground(vararg params: Void?): IconCompat? { var icon: IconCompat? = null val contactPhoto = thread.contactPhoto @@ -228,6 +206,7 @@ object ConversationMenuHelper { return icon } + @Deprecated("Deprecated in Java") override fun onPostExecute(icon: IconCompat?) { val name = Optional.fromNullable<String>(thread.name) .or(Optional.fromNullable<String>(thread.profileName)) @@ -244,9 +223,9 @@ object ConversationMenuHelper { }.execute() } - private fun showExpiringMessagesDialog(context: Context, thread: Recipient) { + private fun showDisappearingMessages(context: Context, thread: Recipient) { val listener = context as? ConversationMenuListener ?: return - listener.showExpiringMessagesDialog(thread) + listener.showDisappearingMessages(thread) } private fun unblock(context: Context, thread: Recipient) { @@ -348,7 +327,7 @@ object ConversationMenuHelper { fun unblock() fun copySessionID(sessionId: String) fun copyOpenGroupUrl(thread: Recipient) - fun showExpiringMessagesDialog(thread: Recipient) + fun showDisappearingMessages(thread: Recipient) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index 3e370104ea..88df4c4508 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -3,50 +3,80 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater -import android.view.View import android.widget.LinearLayout import androidx.core.content.res.ResourcesCompat +import androidx.core.view.isGone +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.ViewControlMessageBinding +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages +import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import javax.inject.Inject +@AndroidEntryPoint class ControlMessageView : LinearLayout { + private val TAG = "ControlMessageView" + private lateinit var binding: ViewControlMessageBinding - // region Lifecycle constructor(context: Context) : super(context) { initialize() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() } + @Inject lateinit var disappearingMessages: DisappearingMessages + private fun initialize() { binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true) layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) } - // endregion - // region Updating fun bind(message: MessageRecord, previous: MessageRecord?) { binding.dateBreakTextView.showDateBreak(message, previous) - binding.iconImageView.visibility = View.GONE + binding.iconImageView.isGone = true + binding.expirationTimerView.isGone = true + binding.followSetting.isGone = true var messageBody: CharSequence = message.getDisplayBody(context) - binding.root.contentDescription= null + binding.root.contentDescription = null + binding.textView.text = messageBody when { message.isExpirationTimerUpdate -> { - binding.iconImageView.setImageDrawable( - ResourcesCompat.getDrawable(resources, R.drawable.ic_timer, context.theme) - ) - binding.iconImageView.visibility = View.VISIBLE + binding.apply { + expirationTimerView.isVisible = true + + val threadRecipient = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(message.threadId) + + if (threadRecipient?.isClosedGroupRecipient == true) { + expirationTimerView.setTimerIcon() + } else { + expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) + } + + followSetting.isVisible = ExpirationConfiguration.isNewConfigEnabled + && !message.isOutgoing + && message.expiryMode != (MessagingModuleConfiguration.shared.storage.getExpirationConfiguration(message.threadId)?.expiryMode ?: ExpiryMode.NONE) + && threadRecipient?.isGroupRecipient != true + + followSetting.setOnClickListener { disappearingMessages.showFollowSettingDialog(context, message) } + } } message.isMediaSavedNotification -> { - binding.iconImageView.setImageDrawable( - ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme) - ) - binding.iconImageView.visibility = View.VISIBLE + binding.iconImageView.apply { + setImageDrawable( + ResourcesCompat.getDrawable(resources, R.drawable.ic_file_download_white_36dp, context.theme) + ) + isVisible = true + } } message.isMessageRequestResponse -> { - messageBody = context.getString(R.string.message_requests_accepted) + binding.textView.text = context.getString(R.string.message_requests_accepted) binding.root.contentDescription=context.getString(R.string.AccessibilityId_message_request_config_message) } message.isCallLog -> { @@ -56,16 +86,22 @@ class ControlMessageView : LinearLayout { message.isFirstMissedCall -> R.drawable.ic_info_outline_light else -> R.drawable.ic_missed_call } - binding.iconImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, drawable, context.theme)) - binding.iconImageView.visibility = View.VISIBLE + binding.textView.isVisible = false + binding.callTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(ResourcesCompat.getDrawable(resources, drawable, context.theme), null, null, null) + binding.callTextView.text = messageBody + + if (message.expireStarted > 0 && message.expiresIn > 0) { + binding.expirationTimerView.isVisible = true + binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) + } } } - binding.textView.text = messageBody + binding.textView.isGone = message.isCallLog + binding.callView.isVisible = message.isCallLog } fun recycle() { } - // endregion } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt index f4f1a2cd97..0614b52e84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DocumentView.kt @@ -5,11 +5,13 @@ import android.content.res.ColorStateList import android.util.AttributeSet import android.widget.LinearLayout import androidx.annotation.ColorInt +import androidx.core.view.isVisible import network.loki.messenger.databinding.ViewDocumentBinding import org.thoughtcrime.securesms.database.model.MmsMessageRecord class DocumentView : LinearLayout { private val binding: ViewDocumentBinding by lazy { ViewDocumentBinding.bind(this) } + // region Lifecycle constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) @@ -22,6 +24,12 @@ class DocumentView : LinearLayout { binding.documentTitleTextView.text = document.fileName.or("Untitled File") binding.documentTitleTextView.setTextColor(textColor) binding.documentViewIconImageView.imageTintList = ColorStateList.valueOf(textColor) + + // Show the progress spinner if the attachment is downloading, otherwise show + // the document icon (and always remove the other, whichever one that is) + binding.documentViewProgress.isVisible = message.isMediaPending + binding.documentViewIconImageView.isVisible = !message.isMediaPending } // endregion + } \ No newline at end of file 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 c812d0f731..7e220955d6 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 @@ -27,6 +27,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment 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.modifyLayoutParams import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ModalUrlBottomSheet @@ -198,9 +199,9 @@ class VisibleMessageContentView : ConstraintLayout { isStart = isStartOfMessageCluster, isEnd = isEndOfMessageCluster ) - val layoutParams = binding.albumThumbnailView.root.layoutParams as ConstraintLayout.LayoutParams - layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f - binding.albumThumbnailView.root.layoutParams = layoutParams + binding.albumThumbnailView.root.modifyLayoutParams<ConstraintLayout.LayoutParams> { + horizontalBias = if (message.isOutgoing) 1f else 0f + } onContentClick.add { event -> binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload) } @@ -233,9 +234,9 @@ class VisibleMessageContentView : ConstraintLayout { } } } - val layoutParams = binding.contentParent.layoutParams as ConstraintLayout.LayoutParams - layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f - binding.contentParent.layoutParams = layoutParams + binding.contentParent.modifyLayoutParams<ConstraintLayout.LayoutParams> { + horizontalBias = if (message.isOutgoing) 1f else 0f + } } private val onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() @@ -306,16 +307,9 @@ class VisibleMessageContentView : ConstraintLayout { } @ColorInt - fun getTextColor(context: Context, message: MessageRecord): Int { - val colorAttribute = if (message.isOutgoing) { - // sent - R.attr.message_sent_text_color - } else { - // received - R.attr.message_received_text_color - } - return context.getColorFromAttr(colorAttribute) - } + fun getTextColor(context: Context, message: MessageRecord): Int = context.getColorFromAttr( + if (message.isOutgoing) R.attr.message_sent_text_color else R.attr.message_received_text_color + ) } // endregion } 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 9538148fd0..df49785deb 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 @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2.messages +import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.graphics.Canvas @@ -21,7 +22,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.os.bundleOf -import androidx.core.view.isInvisible +import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.marginBottom import dagger.hilt.android.AndroidEntryPoint @@ -30,13 +31,11 @@ import network.loki.messenger.databinding.ViewVisibleMessageBinding import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact.ContactContext import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.ViewUtil import org.session.libsession.utilities.getColorFromAttr +import org.session.libsession.utilities.modifyLayoutParams import org.session.libsignal.utilities.IdPrefix -import org.session.libsignal.utilities.ThreadUtils -import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase @@ -61,9 +60,10 @@ import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sqrt +private const val TAG = "VisibleMessageView" + @AndroidEntryPoint class VisibleMessageView : LinearLayout { - @Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var lokiThreadDb: LokiThreadDatabase @Inject lateinit var lokiApiDb: LokiAPIDatabase @@ -138,8 +138,7 @@ class VisibleMessageView : LinearLayout { val isGroupThread = thread.isGroupRecipient val isStartOfMessageCluster = isStartOfMessageCluster(message, previous, isGroupThread) val isEndOfMessageCluster = isEndOfMessageCluster(message, next, isGroupThread) - // Show profile picture and sender name if this is a group thread AND - // the message is incoming + // Show profile picture and sender name if this is a group thread AND the message is incoming binding.moderatorIconImageView.isVisible = false binding.profilePictureView.visibility = when { thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE @@ -203,43 +202,7 @@ class VisibleMessageView : LinearLayout { binding.dateBreakTextView.text = if (showDateBreak) DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), message.timestamp) else null binding.dateBreakTextView.isVisible = showDateBreak // Message status indicator - if (message.isOutgoing) { - val (iconID, iconColor, textId, contentDescription) = getMessageStatusImage(message) - if (textId != null) { - binding.messageStatusTextView.setText(textId) - - if (iconColor != null) { - binding.messageStatusTextView.setTextColor(iconColor) - } - } - if (iconID != null) { - val drawable = ContextCompat.getDrawable(context, iconID)?.mutate() - if (iconColor != null) { - drawable?.setTint(iconColor) - } - binding.messageStatusImageView.setImageDrawable(drawable) - } - binding.messageStatusImageView.contentDescription = contentDescription - - val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId) - binding.messageStatusTextView.isVisible = ( - textId != null && ( - !message.isSent || - message.id == lastMessageID - ) - ) - binding.messageStatusImageView.isVisible = ( - iconID != null && ( - !message.isSent || - message.id == lastMessageID - ) - ) - } else { - binding.messageStatusTextView.isVisible = false - binding.messageStatusImageView.isVisible = false - } - // Expiration timer - updateExpirationTimer(message) + showStatusMessage(message) // Emoji Reactions val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f @@ -274,122 +237,106 @@ class VisibleMessageView : LinearLayout { onDoubleTap = { binding.messageContentView.root.onContentDoubleTap?.invoke() } } - private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean { - return if (isGroupThread) { - previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp) - || current.recipient.address != previous.recipient.address + private fun showStatusMessage(message: MessageRecord) { + val disappearing = message.expiresIn > 0 + + binding.messageInnerLayout.modifyLayoutParams<FrameLayout.LayoutParams> { + gravity = if (message.isOutgoing) Gravity.END else Gravity.START + } + + binding.statusContainer.modifyLayoutParams<ConstraintLayout.LayoutParams> { + horizontalBias = if (message.isOutgoing) 1f else 0f + } + + binding.expirationTimerView.isGone = true + + if (message.isOutgoing || disappearing) { + val (iconID, iconColor, textId) = getMessageStatusImage(message) + textId?.let(binding.messageStatusTextView::setText) + iconColor?.let(binding.messageStatusTextView::setTextColor) + iconID?.let { ContextCompat.getDrawable(context, it) } + ?.run { iconColor?.let { mutate().apply { setTint(it) } } ?: this } + ?.let(binding.messageStatusImageView::setImageDrawable) + + val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId) + val isLastMessage = message.id == lastMessageID + binding.messageStatusTextView.isVisible = + textId != null && (!message.isSent || isLastMessage || disappearing) + val showTimer = disappearing && !message.isPending + binding.messageStatusImageView.isVisible = + iconID != null && !showTimer && (!message.isSent || isLastMessage) + + binding.messageStatusImageView.bringToFront() + binding.expirationTimerView.bringToFront() + binding.expirationTimerView.isVisible = showTimer + if (showTimer) updateExpirationTimer(message) } else { - previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp) - || current.isOutgoing != previous.isOutgoing + binding.messageStatusTextView.isVisible = false + binding.messageStatusImageView.isVisible = false } } - private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean { - return if (isGroupThread) { - next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp) - || current.recipient.address != next.recipient.address + private fun isStartOfMessageCluster(current: MessageRecord, previous: MessageRecord?, isGroupThread: Boolean): Boolean = + previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp) || if (isGroupThread) { + current.recipient.address != previous.recipient.address } else { - next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp) - || current.isOutgoing != next.isOutgoing + current.isOutgoing != previous.isOutgoing + } + + private fun isEndOfMessageCluster(current: MessageRecord, next: MessageRecord?, isGroupThread: Boolean): Boolean = + next == null || next.isUpdate || !DateUtils.isSameHour(current.timestamp, next.timestamp) || if (isGroupThread) { + current.recipient.address != next.recipient.address + } else { + current.isOutgoing != next.isOutgoing } - } data class MessageStatusInfo(@DrawableRes val iconId: Int?, @ColorInt val iconTint: Int?, - @StringRes val messageText: Int?, - val contentDescription: String?) + @StringRes val messageText: Int?) private fun getMessageStatusImage(message: MessageRecord): MessageStatusInfo = when { message.isFailed -> MessageStatusInfo( R.drawable.ic_delivery_status_failed, resources.getColor(R.color.destructive, context.theme), - R.string.delivery_status_failed, - null + R.string.delivery_status_failed ) message.isSyncFailed -> MessageStatusInfo( R.drawable.ic_delivery_status_failed, context.getColor(R.color.accent_orange), - R.string.delivery_status_sync_failed, - null + R.string.delivery_status_sync_failed ) message.isPending -> MessageStatusInfo( R.drawable.ic_delivery_status_sending, - context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending, - context.getString(R.string.AccessibilityId_message_sent_status_pending) + context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_sending ) message.isResyncing -> MessageStatusInfo( R.drawable.ic_delivery_status_sending, - context.getColor(R.color.accent_orange), R.string.delivery_status_syncing, - context.getString(R.string.AccessibilityId_message_sent_status_syncing) + context.getColor(R.color.accent_orange), R.string.delivery_status_syncing ) - message.isRead -> + message.isRead || !message.isOutgoing -> MessageStatusInfo( R.drawable.ic_delivery_status_read, - context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read, - null + context.getColorFromAttr(R.attr.message_status_color), R.string.delivery_status_read ) else -> MessageStatusInfo( R.drawable.ic_delivery_status_sent, context.getColorFromAttr(R.attr.message_status_color), - R.string.delivery_status_sent, - context.getString(R.string.AccessibilityId_message_sent_status_tick) + R.string.delivery_status_sent ) } private fun updateExpirationTimer(message: MessageRecord) { - val container = binding.messageInnerContainer - 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 - if (message.expiresIn > 0 && !message.isPending) { - binding.expirationTimerView.setColorFilter(context.getColorFromAttr(android.R.attr.textColorPrimary)) - binding.expirationTimerView.isInvisible = false - binding.expirationTimerView.setPercentComplete(0.0f) - if (message.expireStarted > 0) { - binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) - binding.expirationTimerView.startAnimation() - if (message.expireStarted + message.expiresIn <= SnodeAPI.nowWithOffset) { - ApplicationContext.getInstance(context).expiringMessageManager.checkSchedule() - } - } else if (!message.isMediaPending) { - binding.expirationTimerView.setPercentComplete(0.0f) - binding.expirationTimerView.stopAnimation() - ThreadUtils.queue { - val expirationManager = ApplicationContext.getInstance(context).expiringMessageManager - val id = message.getId() - val mms = message.isMms - if (mms) mmsDb.markExpireStarted(id) else smsDb.markExpireStarted(id) - expirationManager.scheduleDeletion(id, mms, message.expiresIn) - } - } else { - binding.expirationTimerView.stopAnimation() - binding.expirationTimerView.setPercentComplete(0.0f) - } - } else { - binding.expirationTimerView.isInvisible = true - } - container.requestLayout() + if (!message.isOutgoing) binding.messageStatusTextView.bringToFront() + binding.expirationTimerView.setExpirationTime(message.expireStarted, message.expiresIn) } private fun handleIsSelectedChanged() { - background = if (snIsSelected) { - ColorDrawable(context.getColorFromAttr(R.attr.message_selected)) - } else { - null - } + background = if (snIsSelected) ColorDrawable(context.getColorFromAttr(R.attr.message_selected)) else null } override fun onDraw(canvas: Canvas) { @@ -426,6 +373,7 @@ class VisibleMessageView : LinearLayout { // endregion // region Interaction + @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { if (onPress == null || onSwipeToReply == null || onLongPress == null) { return false } when (event.action) { @@ -526,14 +474,13 @@ class VisibleMessageView : LinearLayout { } private fun maybeShowUserDetails(publicKey: String, threadID: Long) { - val userDetailsBottomSheet = UserDetailsBottomSheet() - val bundle = bundleOf( + UserDetailsBottomSheet().apply { + arguments = bundleOf( UserDetailsBottomSheet.ARGUMENT_PUBLIC_KEY to publicKey, UserDetailsBottomSheet.ARGUMENT_THREAD_ID to threadID - ) - userDetailsBottomSheet.arguments = bundle - val activity = context as AppCompatActivity - userDetailsBottomSheet.show(activity.supportFragmentManager, userDetailsBottomSheet.tag) + ) + show((this@VisibleMessageView.context as AppCompatActivity).supportFragmentManager, tag) + } } fun playVoiceMessage() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailProgressBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailProgressBar.kt index 3abfd235ce..d5ef6434ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailProgressBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailProgressBar.kt @@ -30,9 +30,7 @@ class ThumbnailProgressBar: View { private val objectRect = Rect() private val drawingRect = Rect() - override fun dispatchDraw(canvas: Canvas?) { - if (canvas == null) return - + override fun dispatchDraw(canvas: Canvas) { getDrawingRect(objectRect) drawingRect.set(objectRect) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt new file mode 100644 index 0000000000..a65c22545b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationConfigurationDatabase.kt @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.database + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.messages.ExpirationDatabaseMetadata +import org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX +import org.session.libsession.utilities.GroupUtil.OPEN_GROUP_INBOX_PREFIX +import org.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper + +class ExpirationConfigurationDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { + + companion object { + const val TABLE_NAME = "expiration_configuration" + const val THREAD_ID = "thread_id" + const val UPDATED_TIMESTAMP_MS = "updated_timestamp_ms" + + @JvmField + val CREATE_EXPIRATION_CONFIGURATION_TABLE_COMMAND = """ + CREATE TABLE $TABLE_NAME ( + $THREAD_ID INTEGER NOT NULL PRIMARY KEY ON CONFLICT REPLACE, + $UPDATED_TIMESTAMP_MS INTEGER DEFAULT NULL + ) + """.trimIndent() + + @JvmField + val MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND = """ + INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID} + FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME} + WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} LIKE '$CLOSED_GROUP_PREFIX%' + AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0) + """.trimIndent() + + @JvmField + val MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND = """ + INSERT INTO $TABLE_NAME ($THREAD_ID) SELECT ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ID} + FROM ${ThreadDatabase.TABLE_NAME}, ${RecipientDatabase.TABLE_NAME} + WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$CLOSED_GROUP_PREFIX%' + AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$OPEN_GROUP_PREFIX%' + AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$OPEN_GROUP_INBOX_PREFIX%' + AND EXISTS (SELECT ${RecipientDatabase.EXPIRE_MESSAGES} FROM ${RecipientDatabase.TABLE_NAME} WHERE ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ADDRESS} AND ${RecipientDatabase.EXPIRE_MESSAGES} > 0) + """.trimIndent() + + private fun readExpirationConfiguration(cursor: Cursor): ExpirationDatabaseMetadata { + return ExpirationDatabaseMetadata( + threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)), + updatedTimestampMs = cursor.getLong(cursor.getColumnIndexOrThrow(UPDATED_TIMESTAMP_MS)) + ) + } + } + + fun getExpirationConfiguration(threadId: Long): ExpirationDatabaseMetadata? { + val query = "$THREAD_ID = ?" + val args = arrayOf("$threadId") + + val configurations: MutableList<ExpirationDatabaseMetadata> = mutableListOf() + + readableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor -> + while (cursor.moveToNext()) { + configurations += readExpirationConfiguration(cursor) + } + } + + return configurations.firstOrNull() + } + + fun setExpirationConfiguration(configuration: ExpirationConfiguration) { + writableDatabase.beginTransaction() + try { + val values = ContentValues().apply { + put(THREAD_ID, configuration.threadId) + put(UPDATED_TIMESTAMP_MS, configuration.updatedTimestampMs) + } + + writableDatabase.insert(TABLE_NAME, null, values) + writableDatabase.setTransactionSuccessful() + notifyConversationListeners(configuration.threadId) + } finally { + writableDatabase.endTransaction() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt new file mode 100644 index 0000000000..40d38e8b70 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.database + +data class ExpirationInfo( + val id: Long, + val timestamp: Long, + val expiresIn: Long, + val expireStarted: Long, + val isMms: Boolean +) { + private fun isDisappearAfterSend() = timestamp == expireStarted + fun isDisappearAfterRead() = expiresIn > 0 && !isDisappearAfterSend() +} 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 53f4ea3196..f60c53bbe3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -97,6 +97,16 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( public val groupPublicKey = "group_public_key" @JvmStatic val createClosedGroupPublicKeysTable = "CREATE TABLE $closedGroupPublicKeysTable ($groupPublicKey STRING PRIMARY KEY)" + + private const val LAST_LEGACY_MESSAGE_TABLE = "last_legacy_messages" + // The overall "thread recipient + private const val LAST_LEGACY_THREAD_RECIPIENT = "last_legacy_thread_recipient" + // The individual 'last' person who sent the message with legacy expiration attached + private const val LAST_LEGACY_SENDER_RECIPIENT = "last_legacy_sender_recipient" + private const val LEGACY_THREAD_RECIPIENT_QUERY = "$LAST_LEGACY_THREAD_RECIPIENT = ?" + + const val CREATE_LAST_LEGACY_MESSAGE_TABLE = "CREATE TABLE $LAST_LEGACY_MESSAGE_TABLE ($LAST_LEGACY_THREAD_RECIPIENT STRING PRIMARY KEY, $LAST_LEGACY_SENDER_RECIPIENT STRING NOT NULL);" + // Hard fork service node info const val FORK_INFO_TABLE = "fork_info" const val DUMMY_KEY = "dummy_key" @@ -415,6 +425,31 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( database.endTransaction() } + override fun getLastLegacySenderAddress(threadRecipientAddress: String): String? = + databaseHelper.readableDatabase.get(LAST_LEGACY_MESSAGE_TABLE, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress)) { cursor -> + cursor.getString(LAST_LEGACY_SENDER_RECIPIENT) + } + + override fun setLastLegacySenderAddress( + threadRecipientAddress: String, + senderRecipientAddress: String? + ) { + val database = databaseHelper.writableDatabase + if (senderRecipientAddress == null) { + // delete + database.delete(LAST_LEGACY_MESSAGE_TABLE, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress)) + } else { + // just update the value to a new one + val values = wrap( + mapOf( + LAST_LEGACY_THREAD_RECIPIENT to threadRecipientAddress, + LAST_LEGACY_SENDER_RECIPIENT to senderRecipientAddress + ) + ) + database.insertOrUpdate(LAST_LEGACY_MESSAGE_TABLE, values, LEGACY_THREAD_RECIPIENT_QUERY, wrap(threadRecipientAddress)) + } + } + fun getUserCount(room: String, server: String): Int? { val database = databaseHelper.readableDatabase val index = "$server.$room" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt index 45184c2d23..441c979108 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -13,6 +13,8 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab private val messageThreadMappingTable = "loki_message_thread_mapping_database" private val errorMessageTable = "loki_error_message_database" private val messageHashTable = "loki_message_hash_database" + private val smsHashTable = "loki_sms_hash_database" + private val mmsHashTable = "loki_mms_hash_database" private val messageID = "message_id" private val serverID = "server_id" private val friendRequestStatus = "friend_request_status" @@ -32,6 +34,10 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab val updateMessageMappingTable = "ALTER TABLE $messageThreadMappingTable ADD COLUMN $serverID INTEGER DEFAULT 0; ALTER TABLE $messageThreadMappingTable ADD CONSTRAINT PK_$messageThreadMappingTable PRIMARY KEY ($messageID, $serverID);" @JvmStatic val createMessageHashTableCommand = "CREATE TABLE IF NOT EXISTS $messageHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);" + @JvmStatic + val createMmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $mmsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);" + @JvmStatic + val createSmsHashTableCommand = "CREATE TABLE IF NOT EXISTS $smsHashTable ($messageID INTEGER PRIMARY KEY, $serverHash STRING);" const val SMS_TYPE = 0 const val MMS_TYPE = 1 @@ -201,52 +207,52 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab messages.add(cursor.getLong(messageID) to cursor.getLong(serverID)) } } - var deletedCount = 0L database.beginTransaction() messages.forEach { (messageId, serverId) -> - deletedCount += database.delete(messageIDTable, "$messageID = ? AND $serverID = ?", arrayOf(messageId.toString(), serverId.toString())) + database.delete(messageIDTable, "$messageID = ? AND $serverID = ?", arrayOf(messageId.toString(), serverId.toString())) } - val mappingDeleted = database.delete(messageThreadMappingTable, "$threadID = ?", arrayOf(threadId.toString())) + database.delete(messageThreadMappingTable, "$threadID = ?", arrayOf(threadId.toString())) database.setTransactionSuccessful() } finally { database.endTransaction() } } - fun getMessageServerHash(messageID: Long): String? { - val database = databaseHelper.readableDatabase - return database.get(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor -> + fun getMessageServerHash(messageID: Long, mms: Boolean): String? = getMessageTables(mms).firstNotNullOfOrNull { + databaseHelper.readableDatabase.get(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) { cursor -> cursor.getString(serverHash) } } - fun setMessageServerHash(messageID: Long, serverHash: String) { - val database = databaseHelper.writableDatabase - val contentValues = ContentValues(2) - contentValues.put(Companion.messageID, messageID) - contentValues.put(Companion.serverHash, serverHash) - database.insertOrUpdate(messageHashTable, contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString())) + fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) { + val contentValues = ContentValues(2).apply { + put(Companion.messageID, messageID) + put(Companion.serverHash, serverHash) + } + + databaseHelper.writableDatabase.apply { + insertOrUpdate(getMessageTable(mms), contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString())) + } } - fun deleteMessageServerHash(messageID: Long) { - val database = databaseHelper.writableDatabase - database.delete(messageHashTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) + fun deleteMessageServerHash(messageID: Long, mms: Boolean) { + getMessageTables(mms).firstOrNull { + databaseHelper.writableDatabase.delete(it, "${Companion.messageID} = ?", arrayOf(messageID.toString())) > 0 + } } - fun deleteMessageServerHashes(messageIDs: List<Long>) { - val database = databaseHelper.writableDatabase - database.delete( - messageHashTable, - "${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})", + fun deleteMessageServerHashes(messageIDs: List<Long>, mms: Boolean) { + databaseHelper.writableDatabase.delete( + getMessageTable(mms), + "${Companion.messageID} IN (${messageIDs.joinToString(",") { "?" }})", messageIDs.map { "$it" }.toTypedArray() ) } - fun migrateThreadId(legacyThreadId: Long, newThreadId: Long) { - val database = databaseHelper.writableDatabase - val contentValues = ContentValues(1) - contentValues.put(threadID, newThreadId) - database.update(messageThreadMappingTable, contentValues, "$threadID = ?", arrayOf(legacyThreadId.toString())) - } + private fun getMessageTables(mms: Boolean) = sequenceOf( + getMessageTable(mms), + messageHashTable + ) + private fun getMessageTable(mms: Boolean) = if (mms) mmsHashTable else smsHashTable } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MarkedMessageInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MarkedMessageInfo.kt new file mode 100644 index 0000000000..9de3dac695 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MarkedMessageInfo.kt @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.database + +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId + +data class MarkedMessageInfo(val syncMessageId: SyncMessageId, val expirationInfo: ExpirationInfo) { + val expiryType get() = when { + syncMessageId.timetamp == expirationInfo.expireStarted -> ExpiryType.AFTER_SEND + expirationInfo.expiresIn > 0 -> ExpiryType.AFTER_READ + else -> ExpiryType.NONE + } + + val expiryMode get() = expiryType.mode(expirationInfo.expiresIn) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java index edc6bc1a6f..bc74496dda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -14,6 +14,7 @@ import org.session.libsession.utilities.IdentityKeyMismatchList; import org.session.libsignal.crypto.IdentityKey; import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.Log; +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.util.SqlUtil; @@ -33,7 +34,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn protected abstract String getTableName(); - public abstract void markExpireStarted(long messageId); public abstract void markExpireStarted(long messageId, long startTime); public abstract void markAsSent(long messageId, boolean secure); @@ -225,56 +225,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn } } - public static class ExpirationInfo { - - private final long id; - private final long expiresIn; - private final long expireStarted; - private final boolean mms; - - public ExpirationInfo(long id, long expiresIn, long expireStarted, boolean mms) { - this.id = id; - this.expiresIn = expiresIn; - this.expireStarted = expireStarted; - this.mms = mms; - } - - public long getId() { - return id; - } - - public long getExpiresIn() { - return expiresIn; - } - - public long getExpireStarted() { - return expireStarted; - } - - public boolean isMms() { - return mms; - } - } - - public static class MarkedMessageInfo { - - private final SyncMessageId syncMessageId; - private final ExpirationInfo expirationInfo; - - public MarkedMessageInfo(SyncMessageId syncMessageId, ExpirationInfo expirationInfo) { - this.syncMessageId = syncMessageId; - this.expirationInfo = expirationInfo; - } - - public SyncMessageId getSyncMessageId() { - return syncMessageId; - } - - public ExpirationInfo getExpirationInfo() { - return expirationInfo; - } - } - public static class InsertResult { private final long messageId; private final long threadId; 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 111b6d5365..62db50a1ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -19,11 +19,13 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor +import android.provider.ContactsContract.CommonDataKinds.BaseTypes import com.annimon.stream.Stream 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.ExpirationConfiguration import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage @@ -222,6 +224,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return readerFor(rawQuery(where, null))!! } + val expireNotStartedMessages: Reader + get() { + val where = "$EXPIRES_IN > 0 AND $EXPIRE_STARTED = 0" + return readerFor(rawQuery(where, null))!! + } + private fun updateMailboxBitmask( id: Long, maskOff: Long, @@ -296,10 +304,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId) } - override fun markExpireStarted(messageId: Long) { - markExpireStarted(messageId, SnodeAPI.nowWithOffset) - } - override fun markExpireStarted(messageId: Long, startedTimestamp: Long) { val contentValues = ContentValues() contentValues.put(EXPIRE_STARTED, startedTimestamp) @@ -347,13 +351,14 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) while (cursor != null && cursor.moveToNext()) { if (MmsSmsColumns.Types.isSecureType(cursor.getLong(3))) { - val syncMessageId = - SyncMessageId(fromSerialized(cursor.getString(1)), cursor.getLong(2)) + val timestamp = cursor.getLong(2) + val syncMessageId = SyncMessageId(fromSerialized(cursor.getString(1)), timestamp) val expirationInfo = ExpirationInfo( - cursor.getLong(0), - cursor.getLong(4), - cursor.getLong(5), - true + id = cursor.getLong(0), + timestamp = timestamp, + expiresIn = cursor.getLong(4), + expireStarted = cursor.getLong(5), + isMms = true ) result.add(MarkedMessageInfo(syncMessageId, expirationInfo)) } @@ -383,6 +388,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT)) val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID)) val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)) + val expireStartedAt = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED)) val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS)) val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) val distributionType = get(context).threadDatabase().getDistributionType(threadId) @@ -451,6 +457,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa timestamp, subscriptionId, expiresIn, + expireStartedAt, distributionType, quote, contacts, @@ -550,6 +557,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa runThreadUpdate: Boolean ): Optional<InsertResult> { if (threadId < 0 ) throw MmsException("No thread ID supplied!") + if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, false.takeUnless { retrieved.groupId != null }) val contentValues = ContentValues() contentValues.put(DATE_SENT, retrieved.sentTimeMillis) contentValues.put(ADDRESS, retrieved.from.serialize()) @@ -570,7 +578,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa contentValues.put(PART_COUNT, retrieved.attachments.size) contentValues.put(SUBSCRIPTION_ID, retrieved.subscriptionId) contentValues.put(EXPIRES_IN, retrieved.expiresIn) - contentValues.put(READ, if (retrieved.isExpirationUpdate) 1 else 0) + contentValues.put(EXPIRE_STARTED, retrieved.expireStartedAt) contentValues.put(UNIDENTIFIED, retrieved.isUnidentified) contentValues.put(HAS_MENTION, retrieved.hasMention()) contentValues.put(MESSAGE_REQUEST_RESPONSE, retrieved.isMessageRequestResponse) @@ -619,6 +627,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa runThreadUpdate: Boolean ): Optional<InsertResult> { if (threadId < 0 ) throw MmsException("No thread ID supplied!") + if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, true.takeUnless { retrieved.isGroup }) val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate) if (messageId == -1L) { return Optional.absent() @@ -689,6 +698,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa contentValues.put(DATE_RECEIVED, receivedTimestamp) contentValues.put(SUBSCRIPTION_ID, message.subscriptionId) contentValues.put(EXPIRES_IN, message.expiresIn) + contentValues.put(EXPIRE_STARTED, message.expireStartedAt) contentValues.put(ADDRESS, message.recipient.address.serialize()) contentValues.put( DELIVERY_RECEIPT_COUNT, @@ -1152,6 +1162,20 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) } + /** + * @param outgoing if true only delete outgoing messages, if false only delete incoming messages, if null delete both. + */ + private fun deleteExpirationTimerMessages(threadId: Long, outgoing: Boolean? = null) { + val outgoingClause = outgoing?.takeIf { ExpirationConfiguration.isNewConfigEnabled }?.let { + val comparison = if (it) "IN" else "NOT IN" + " AND $MESSAGE_BOX & ${MmsSmsColumns.Types.BASE_TYPE_MASK} $comparison (${MmsSmsColumns.Types.OUTGOING_MESSAGE_TYPES.joinToString()})" + } ?: "" + + val where = "$THREAD_ID = ? AND ($MESSAGE_BOX & ${MmsSmsColumns.Types.EXPIRATION_TIMER_UPDATE_BIT}) <> 0" + outgoingClause + writableDatabase.delete(TABLE_NAME, where, arrayOf("$threadId")) + notifyConversationListeners(threadId) + } + object Status { const val DOWNLOAD_INITIALIZED = 1 const val DOWNLOAD_NO_CONNECTIVITY = 2 @@ -1398,7 +1422,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa const val SHARED_CONTACTS: String = "shared_contacts" const val LINK_PREVIEWS: String = "previews" const val CREATE_TABLE: String = - "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + + "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + THREAD_ID + " INTEGER, " + DATE_SENT + " INTEGER, " + DATE_RECEIVED + " INTEGER, " + MESSAGE_BOX + " INTEGER, " + READ + " INTEGER DEFAULT 0, " + "m_id" + " TEXT, " + "sub" + " TEXT, " + "sub_cs" + " INTEGER, " + BODY + " TEXT, " + PART_COUNT + " INTEGER, " + @@ -1503,5 +1527,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa const val CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_UNREAD INTEGER DEFAULT 0;" const val CREATE_REACTIONS_LAST_SEEN_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $REACTIONS_LAST_SEEN INTEGER DEFAULT 0;" const val CREATE_HAS_MENTION_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $HAS_MENTION INTEGER DEFAULT 0;" + + private const val TEMP_TABLE_NAME = "TEMP_TABLE_NAME" + + const val COMMA_SEPARATED_COLUMNS = "$ID, $THREAD_ID, $DATE_SENT, $DATE_RECEIVED, $MESSAGE_BOX, $READ, m_id, sub, sub_cs, $BODY, $PART_COUNT, ct_t, $CONTENT_LOCATION, $ADDRESS, $ADDRESS_DEVICE_ID, $EXPIRY, m_cls, $MESSAGE_TYPE, v, $MESSAGE_SIZE, pri, rr,rpt_a, resp_st, $STATUS, $TRANSACTION_ID, retr_st, retr_txt, retr_txt_cs, read_status, ct_cls, resp_txt, d_tm, $DELIVERY_RECEIPT_COUNT, $MISMATCHED_IDENTITIES, $NETWORK_FAILURE, d_rpt, $SUBSCRIPTION_ID, $EXPIRES_IN, $EXPIRE_STARTED, $NOTIFIED, $READ_RECEIPT_COUNT, $QUOTE_ID, $QUOTE_AUTHOR, $QUOTE_BODY, $QUOTE_ATTACHMENT, $QUOTE_MISSING, $SHARED_CONTACTS, $UNIDENTIFIED, $LINK_PREVIEWS, $MESSAGE_REQUEST_RESPONSE, $REACTIONS_UNREAD, $REACTIONS_LAST_SEEN, $HAS_MENTION" + + @JvmField + val ADD_AUTOINCREMENT = arrayOf( + "ALTER TABLE $TABLE_NAME RENAME TO $TEMP_TABLE_NAME", + CREATE_TABLE, + CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND, + CREATE_REACTIONS_UNREAD_COMMAND, + CREATE_REACTIONS_LAST_SEEN_COMMAND, + CREATE_HAS_MENTION_COMMAND, + "INSERT INTO $TABLE_NAME ($COMMA_SEPARATED_COLUMNS) SELECT $COMMA_SEPARATED_COLUMNS FROM $TEMP_TABLE_NAME", + "DROP TABLE $TEMP_TABLE_NAME" + ) } } 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 dde5847ffe..69c9fa87f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -46,7 +46,8 @@ public class RecipientDatabase extends Database { private static final String COLOR = "color"; private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder"; private static final String DEFAULT_SUBSCRIPTION_ID = "default_subscription_id"; - private static final String EXPIRE_MESSAGES = "expire_messages"; + static final String EXPIRE_MESSAGES = "expire_messages"; + private static final String DISAPPEARING_STATE = "disappearing_state"; private static final String REGISTERED = "registered"; private static final String PROFILE_KEY = "profile_key"; private static final String SYSTEM_DISPLAY_NAME = "system_display_name"; @@ -70,7 +71,7 @@ public class RecipientDatabase extends Database { 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, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS + FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS }; static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) @@ -138,6 +139,11 @@ public class RecipientDatabase extends Database { "OR "+ADDRESS+" IN (SELECT "+GroupDatabase.TABLE_NAME+"."+GroupDatabase.ADMINS+" FROM "+GroupDatabase.TABLE_NAME+")))"; } + public static String getCreateDisappearingStateCommand() { + return "ALTER TABLE "+ TABLE_NAME + " " + + "ADD COLUMN " + DISAPPEARING_STATE + " INTEGER DEFAULT 0;"; + } + public static String getAddWrapperHash() { return "ALTER TABLE "+TABLE_NAME+" "+ "ADD COLUMN "+WRAPPER_HASH+" TEXT DEFAULT NULL;"; @@ -183,6 +189,7 @@ public class RecipientDatabase extends Database { boolean approvedMe = cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED_ME)) == 1; String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION)); String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE)); + int disappearingState = cursor.getInt(cursor.getColumnIndexOrThrow(DISAPPEARING_STATE)); int messageVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(VIBRATE)); int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE)); long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL)); @@ -226,6 +233,7 @@ public class RecipientDatabase extends Database { return Optional.of(new RecipientSettings(blocked, approved, approvedMe, muteUntil, notifyType, + Recipient.DisappearingState.fromId(disappearingState), Recipient.VibrateState.fromId(messageVibrateState), Recipient.VibrateState.fromId(callVibrateState), Util.uri(messageRingtone), Util.uri(callRingtone), @@ -335,16 +343,6 @@ public class RecipientDatabase extends Database { notifyRecipientListeners(); } - public void setExpireMessages(@NonNull Recipient recipient, int expiration) { - recipient.setExpireMessages(expiration); - - ContentValues values = new ContentValues(1); - values.put(EXPIRE_MESSAGES, expiration); - updateOrInsert(recipient.getAddress(), values); - recipient.resolve().setExpireMessages(expiration); - notifyRecipientListeners(); - } - public void setUnidentifiedAccessMode(@NonNull Recipient recipient, @NonNull UnidentifiedAccessMode unidentifiedAccessMode) { ContentValues values = new ContentValues(1); values.put(UNIDENTIFIED_ACCESS_MODE, unidentifiedAccessMode.getMode()); @@ -443,6 +441,14 @@ public class RecipientDatabase extends Database { return returnList; } + public void setDisappearingState(@NonNull Recipient recipient, @NonNull Recipient.DisappearingState disappearingState) { + ContentValues values = new ContentValues(); + values.put(DISAPPEARING_STATE, disappearingState.getId()); + updateOrInsert(recipient.getAddress(), values); + recipient.resolve().setDisappearingState(disappearingState); + notifyRecipientListeners(); + } + public static class RecipientReader implements Closeable { private final Context context; 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 6221446aae..591755b88f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import android.database.Cursor +import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob @@ -26,6 +27,9 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa const val serializedData = "serialized_data" @JvmStatic val createSessionJobTableCommand = "CREATE TABLE $sessionJobTable ($jobID INTEGER PRIMARY KEY, $jobType STRING, $failureCount INTEGER DEFAULT 0, $serializedData TEXT);" + + const val dropAttachmentDownloadJobs = + "DELETE FROM $sessionJobTable WHERE $jobType = '${AttachmentDownloadJob.KEY}';" } fun persistJob(job: Job) { 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 4ef576f404..2c0c33dda8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -52,6 +52,7 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; +import java.io.Closeable; import java.io.IOException; import java.security.SecureRandom; import java.util.Arrays; @@ -90,6 +91,7 @@ public class SmsDatabase extends MessagingDatabase { EXPIRES_IN + " INTEGER DEFAULT 0, " + EXPIRE_STARTED + " INTEGER DEFAULT 0, " + NOTIFIED + " DEFAULT 0, " + READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNIDENTIFIED + " INTEGER DEFAULT 0);"; + public static final String[] CREATE_INDEXS = { "CREATE INDEX IF NOT EXISTS sms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", "CREATE INDEX IF NOT EXISTS sms_read_index ON " + TABLE_NAME + " (" + READ + ");", @@ -127,6 +129,18 @@ public class SmsDatabase extends MessagingDatabase { public static String CREATE_HAS_MENTION_COMMAND = "ALTER TABLE "+ TABLE_NAME + " " + "ADD COLUMN " + HAS_MENTION + " INTEGER DEFAULT 0;"; + private static String COMMA_SEPARATED_COLUMNS = ID + ", " + THREAD_ID + ", " + ADDRESS + ", " + ADDRESS_DEVICE_ID + ", " + PERSON + ", " + DATE_RECEIVED + ", " + DATE_SENT + ", " + PROTOCOL + ", " + READ + ", " + STATUS + ", " + TYPE + ", " + REPLY_PATH_PRESENT + ", " + DELIVERY_RECEIPT_COUNT + ", " + SUBJECT + ", " + BODY + ", " + MISMATCHED_IDENTITIES + ", " + SERVICE_CENTER + ", " + SUBSCRIPTION_ID + ", " + EXPIRES_IN + ", " + EXPIRE_STARTED + ", " + NOTIFIED + ", " + READ_RECEIPT_COUNT + ", " + UNIDENTIFIED + ", " + REACTIONS_UNREAD + ", " + HAS_MENTION; + private static String TEMP_TABLE_NAME = "TEMP_TABLE_NAME"; + + public static final String[] ADD_AUTOINCREMENT = new String[]{ + "ALTER TABLE " + TABLE_NAME + " RENAME TO " + TEMP_TABLE_NAME, + CREATE_TABLE, + CREATE_REACTIONS_UNREAD_COMMAND, + CREATE_HAS_MENTION_COMMAND, + "INSERT INTO " + TABLE_NAME + " (" + COMMA_SEPARATED_COLUMNS + ") SELECT " + COMMA_SEPARATED_COLUMNS + " FROM " + TEMP_TABLE_NAME, + "DROP TABLE " + TEMP_TABLE_NAME + }; + private static final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache(); private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache(); @@ -237,11 +251,6 @@ public class SmsDatabase extends MessagingDatabase { updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE); } - @Override - public void markExpireStarted(long id) { - markExpireStarted(id, SnodeAPI.getNowWithOffset()); - } - @Override public void markExpireStarted(long id, long startedAtTimestamp) { ContentValues contentValues = new ContentValues(); @@ -354,12 +363,11 @@ public class SmsDatabase extends MessagingDatabase { cursor = database.query(TABLE_NAME, new String[] {ID, ADDRESS, DATE_SENT, TYPE, EXPIRES_IN, EXPIRE_STARTED}, where, arguments, null, null, null); while (cursor != null && cursor.moveToNext()) { - if (Types.isSecureType(cursor.getLong(3))) { - SyncMessageId syncMessageId = new SyncMessageId(Address.fromSerialized(cursor.getString(1)), cursor.getLong(2)); - ExpirationInfo expirationInfo = new ExpirationInfo(cursor.getLong(0), cursor.getLong(4), cursor.getLong(5), false); + long timestamp = cursor.getLong(2); + SyncMessageId syncMessageId = new SyncMessageId(Address.fromSerialized(cursor.getString(1)), timestamp); + ExpirationInfo expirationInfo = new ExpirationInfo(cursor.getLong(0), timestamp, cursor.getLong(4), cursor.getLong(5), false); - results.add(new MarkedMessageInfo(syncMessageId, expirationInfo)); - } + results.add(new MarkedMessageInfo(syncMessageId, expirationInfo)); } ContentValues contentValues = new ContentValues(); @@ -407,6 +415,24 @@ public class SmsDatabase extends MessagingDatabase { } protected Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runThreadUpdate) { + Recipient recipient = Recipient.from(context, message.getSender(), true); + + Recipient groupRecipient; + + if (message.getGroupId() == null) { + groupRecipient = null; + } else { + groupRecipient = Recipient.from(context, message.getGroupId(), true); + } + + boolean unread = (Util.isDefaultSmsProvider(context) || + message.isSecureMessage() || message.isGroup() || message.isCallInfo()); + + long threadId; + + if (groupRecipient == null) threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient); + else threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(groupRecipient); + if (message.isSecureMessage()) { type |= Types.SECURE_MESSAGE_BIT; } else if (message.isGroup()) { @@ -420,40 +446,9 @@ public class SmsDatabase extends MessagingDatabase { CallMessageType callMessageType = message.getCallType(); if (callMessageType != null) { - switch (callMessageType) { - case CALL_OUTGOING: - type |= Types.OUTGOING_CALL_TYPE; - break; - case CALL_INCOMING: - type |= Types.INCOMING_CALL_TYPE; - break; - case CALL_MISSED: - type |= Types.MISSED_CALL_TYPE; - break; - case CALL_FIRST_MISSED: - type |= Types.FIRST_MISSED_CALL_TYPE; - break; - } + type |= getCallMessageTypeMask(callMessageType); } - Recipient recipient = Recipient.from(context, message.getSender(), true); - - Recipient groupRecipient; - - if (message.getGroupId() == null) { - groupRecipient = null; - } else { - groupRecipient = Recipient.from(context, message.getGroupId(), true); - } - - boolean unread = (Util.isDefaultSmsProvider(context) || - message.isSecureMessage() || message.isGroup() || message.isCallInfo()); - - long threadId; - - if (groupRecipient == null) threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient); - else threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(groupRecipient); - ContentValues values = new ContentValues(6); values.put(ADDRESS, message.getSender().serialize()); values.put(ADDRESS_DEVICE_ID, message.getSenderDeviceId()); @@ -466,6 +461,7 @@ public class SmsDatabase extends MessagingDatabase { values.put(READ, unread ? 0 : 1); values.put(SUBSCRIPTION_ID, message.getSubscriptionId()); values.put(EXPIRES_IN, message.getExpiresIn()); + values.put(EXPIRE_STARTED, message.getExpireStartedAt()); values.put(UNIDENTIFIED, message.isUnidentified()); values.put(HAS_MENTION, message.hasMention()); @@ -499,6 +495,21 @@ public class SmsDatabase extends MessagingDatabase { } } + private long getCallMessageTypeMask(CallMessageType callMessageType) { + switch (callMessageType) { + case CALL_OUTGOING: + return Types.OUTGOING_CALL_TYPE; + case CALL_INCOMING: + return Types.INCOMING_CALL_TYPE; + case CALL_MISSED: + return Types.MISSED_CALL_TYPE; + case CALL_FIRST_MISSED: + return Types.FIRST_MISSED_CALL_TYPE; + default: + return 0; + } + } + public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, boolean runThreadUpdate) { return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runThreadUpdate); } @@ -547,6 +558,7 @@ public class SmsDatabase extends MessagingDatabase { contentValues.put(TYPE, type); contentValues.put(SUBSCRIPTION_ID, message.getSubscriptionId()); contentValues.put(EXPIRES_IN, message.getExpiresIn()); + contentValues.put(EXPIRE_STARTED, message.getExpireStartedAt()); contentValues.put(DELIVERY_RECEIPT_COUNT, Stream.of(earlyDeliveryReceipts.values()).mapToLong(Long::longValue).sum()); contentValues.put(READ_RECEIPT_COUNT, Stream.of(earlyReadReceipts.values()).mapToLong(Long::longValue).sum()); @@ -590,6 +602,11 @@ public class SmsDatabase extends MessagingDatabase { return rawQuery(where, null); } + public Cursor getExpirationNotStartedMessages() { + String where = EXPIRES_IN + " > 0 AND " + EXPIRE_STARTED + " = 0"; + return rawQuery(where, null); + } + public SmsMessageRecord getMessage(long messageId) throws NoSuchMessageException { Cursor cursor = rawQuery(ID_WHERE, new String[]{messageId + ""}); Reader reader = new Reader(cursor); @@ -615,7 +632,6 @@ public class SmsDatabase extends MessagingDatabase { long threadId = getThreadIdForMessage(messageId); db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); - notifyConversationListeners(threadId); return threadDeleted; } @@ -787,7 +803,7 @@ public class SmsDatabase extends MessagingDatabase { } } - public class Reader { + public class Reader implements Closeable { private final Cursor cursor; @@ -853,8 +869,11 @@ public class SmsDatabase extends MessagingDatabase { return new LinkedList<>(); } + @Override public void close() { - cursor.close(); + if (cursor != null) { + cursor.close(); + } } } 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 481a4fa06e..584394a86c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -14,6 +14,7 @@ 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 network.loki.messenger.libsession_util.util.afterSend import org.session.libsession.avatars.AvatarHelper import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.BlindedIdMapping @@ -29,6 +30,7 @@ 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.ExpirationConfiguration import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse @@ -66,6 +68,7 @@ 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.libsession.utilities.recipients.Recipient.DisappearingState import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair @@ -89,10 +92,16 @@ import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.SessionMetaProtocol import java.security.MessageDigest +import kotlin.time.Duration.Companion.days 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 { +private const val TAG = "Storage" + +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 @@ -173,7 +182,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } override fun getUserProfile(): Profile { - val displayName = TextSecurePreferences.getProfileName(context)!! + val displayName = TextSecurePreferences.getProfileName(context) val profileKey = ProfileKeyUtil.getProfileKey(context) val profilePictureUrl = TextSecurePreferences.getProfilePictureURL(context) return Profile(displayName, profileKey, profilePictureUrl) @@ -322,19 +331,30 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co // open group recipients should explicitly create threads message.threadID = getOrCreateThreadIdFor(targetAddress) } + val expiryMode = message.expiryMode + val expiresInMillis = expiryMode.expiryMillis + val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) message.sentTimestamp!! else 0 if (message.isMediaMessage() || attachments.isNotEmpty()) { val quote: Optional<QuoteModel> = if (quotes != null) Optional.of(quotes) else Optional.absent() val linkPreviews: Optional<List<LinkPreview>> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! }) val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() val insertResult = if (isUserSender || isUserBlindedSender) { - val mediaMessage = OutgoingMediaMessage.from(message, targetRecipient, pointers, quote.orNull(), linkPreviews.orNull()?.firstOrNull()) + val mediaMessage = OutgoingMediaMessage.from( + message, + targetRecipient, + pointers, + quote.orNull(), + linkPreviews.orNull()?.firstOrNull(), + expiresInMillis, + expireStartedAt + ) mmsDatabase.insertSecureDecryptedMessageOutbox(mediaMessage, message.threadID ?: -1, message.sentTimestamp!!, runThreadUpdate) } else { // It seems like we have replaced SignalServiceAttachment with SessionServiceAttachment val signalServiceAttachments = attachments.mapNotNull { it.toSignalPointer() } - val mediaMessage = IncomingMediaMessage.from(message, senderAddress, targetRecipient.expireMessages * 1000L, group, signalServiceAttachments, quote, linkPreviews) + val mediaMessage = IncomingMediaMessage.from(message, senderAddress, expiresInMillis, expireStartedAt, group, signalServiceAttachments, quote, linkPreviews) mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID!!, message.receivedTimestamp ?: 0, runThreadUpdate) } if (insertResult.isPresent) { @@ -345,12 +365,12 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co val isOpenGroupInvitation = (message.openGroupInvitation != null) val insertResult = if (isUserSender || isUserBlindedSender) { - val textMessage = if (isOpenGroupInvitation) OutgoingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, targetRecipient, message.sentTimestamp) - else OutgoingTextMessage.from(message, targetRecipient) + val textMessage = if (isOpenGroupInvitation) OutgoingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, targetRecipient, message.sentTimestamp, expiresInMillis, expireStartedAt) + else OutgoingTextMessage.from(message, targetRecipient, expiresInMillis, expireStartedAt) smsDatabase.insertMessageOutbox(message.threadID ?: -1, textMessage, message.sentTimestamp!!, runThreadUpdate) } else { - val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp) - else IncomingTextMessage.from(message, senderAddress, group, targetRecipient.expireMessages * 1000L) + val textMessage = if (isOpenGroupInvitation) IncomingTextMessage.fromOpenGroupInvitation(message.openGroupInvitation, senderAddress, message.sentTimestamp, expiresInMillis, expireStartedAt) + else IncomingTextMessage.from(message, senderAddress, group, expiresInMillis, expireStartedAt) val encrypted = IncomingEncryptedMessage(textMessage, textMessage.messageBody) smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runThreadUpdate) } @@ -360,7 +380,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } message.serverHash?.let { serverHash -> messageID?.let { id -> - DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(id, serverHash) + DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(id, message.isMediaMessage(), serverHash) } } return messageID @@ -423,8 +443,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id) } - override fun notifyConfigUpdates(forConfigObject: ConfigBase) { - notifyUpdates(forConfigObject) + override fun notifyConfigUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) { + notifyUpdates(forConfigObject, messageTimestamp) } override fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean { @@ -439,16 +459,16 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co return configFactory.user?.getCommunityMessageRequests() == true } - fun notifyUpdates(forConfigObject: ConfigBase) { + private fun notifyUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) { when (forConfigObject) { - is UserProfile -> updateUser(forConfigObject) - is Contacts -> updateContacts(forConfigObject) - is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject) - is UserGroupsConfig -> updateUserGroups(forConfigObject) + is UserProfile -> updateUser(forConfigObject, messageTimestamp) + is Contacts -> updateContacts(forConfigObject, messageTimestamp) + is ConversationVolatileConfig -> updateConvoVolatile(forConfigObject, messageTimestamp) + is UserGroupsConfig -> updateUserGroups(forConfigObject, messageTimestamp) } } - private fun updateUser(userProfile: UserProfile) { + private fun updateUser(userProfile: UserProfile, messageTimestamp: Long) { val userPublicKey = getUserPublicKey() ?: return // would love to get rid of recipient and context from this val recipient = Recipient.from(context, fromSerialized(userPublicKey), false) @@ -474,16 +494,25 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co deleteConversation(ourThread) } else { // create note to self thread if needed (?) - val ourThread = getOrCreateThreadIdFor(recipient.address) + val address = recipient.address + val ourThread = getThreadId(address) ?: getOrCreateThreadIdFor(address).also { + setThreadDate(it, 0) + } DatabaseComponent.get(context).threadDatabase().setHasSent(ourThread, true) setPinned(ourThread, userProfile.getNtsPriority() > 0) } + // Set or reset the shared library to use latest expiration config + getThreadId(recipient)?.let { + setExpirationConfiguration( + getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } ?: ExpirationConfiguration(it, userProfile.getNtsExpiry(), messageTimestamp) + ) + } } - private fun updateContacts(contacts: Contacts) { + private fun updateContacts(contacts: Contacts, messageTimestamp: Long) { val extracted = contacts.all().toList() - addLibSessionContacts(extracted) + addLibSessionContacts(extracted, messageTimestamp) } override fun clearUserPic() { @@ -503,7 +532,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } - private fun updateConvoVolatile(convos: ConversationVolatileConfig) { + private fun updateConvoVolatile(convos: ConversationVolatileConfig, messageTimestamp: Long) { val extracted = convos.all() for (conversation in extracted) { val threadId = when (conversation) { @@ -520,7 +549,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } } - private fun updateUserGroups(userGroups: UserGroupsConfig) { + private fun updateUserGroups(userGroups: UserGroupsConfig, messageTimestamp: Long) { val threadDb = DatabaseComponent.get(context).threadDatabase() val localUserPublicKey = getUserPublicKey() ?: return Log.w( "Loki", @@ -572,6 +601,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } for (group in lgc) { + val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId) val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId } val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) } if (existingGroup != null) { @@ -586,7 +616,6 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } 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) @@ -596,9 +625,6 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co // 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 PushRegistryV1.subscribeGroup(group.sessionId, publicKey = localUserPublicKey) // Notify the user @@ -609,6 +635,12 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co // Start polling ClosedGroupPollerV2.shared.startPolling(group.sessionId) } + getThreadId(Address.fromSerialized(groupId))?.let { + setExpirationConfiguration( + getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } + ?: ExpirationConfiguration(it, afterSend(group.disappearingTimer), messageTimestamp) + ) + } } } @@ -712,10 +744,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co SessionMetaProtocol.removeTimestamps(timestamps) } - override fun getMessageIdInDatabase(timestamp: Long, author: String): Long? { + override fun getMessageIdInDatabase(timestamp: Long, author: String): Pair<Long, Boolean>? { val database = DatabaseComponent.get(context).mmsSmsDatabase() val address = fromSerialized(author) - return database.getMessageFor(timestamp, address)?.getId() + return database.getMessageFor(timestamp, address)?.run { getId() to isMms } } override fun updateSentTimestamp( @@ -834,8 +866,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co db.clearErrorMessage(messageID) } - override fun setMessageServerHash(messageID: Long, serverHash: String) { - DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(messageID, serverHash) + override fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) { + DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(messageID, mms, serverHash) } override fun getGroup(groupID: String): GroupRecord? { @@ -847,9 +879,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp) } - override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) { + override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair, expirationTimer: Int) { val volatiles = configFactory.convoVolatile ?: return val userGroups = configFactory.userGroups ?: return + if (volatiles.getLegacyClosedGroup(groupPublicKey) != null && userGroups.getLegacyGroupInfo(groupPublicKey) != null) return val groupVolatileConfig = volatiles.getOrConstructLegacyGroup(groupPublicKey) groupVolatileConfig.lastRead = formationTimestamp volatiles.set(groupVolatileConfig) @@ -860,7 +893,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co priority = ConfigBase.PRIORITY_VISIBLE, encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte encSecKey = encryptionKeyPair.privateKey.serialize(), - disappearingTimer = 0L, + disappearingTimer = expirationTimer.toLong(), joinedAt = (formationTimestamp / 1000L) ) // shouldn't exist, don't use getOrConstruct + copy @@ -871,8 +904,6 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co 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 @@ -886,7 +917,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co 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, @@ -894,7 +925,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co 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(), + disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L, joinedAt = (existingGroup.formationTimestamp / 1000L) ) userGroups.set(groupInfo) @@ -926,7 +957,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co override fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, sentTimestamp: Long) { val group = SignalServiceGroup(type, GroupUtil.getDecodedGroupIDAsData(groupID), SignalServiceGroup.GroupType.SIGNAL, name, members.toList(), null, admins.toList()) - val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, true, false) + val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, 0, true, false) val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() val infoMessage = IncomingGroupMessage(m, groupID, updateData, true) val smsDB = DatabaseComponent.get(context).smsDatabase() @@ -934,11 +965,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } override fun insertOutgoingInfoMessage(context: Context, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long) { - val userPublicKey = getUserPublicKey() + val userPublicKey = getUserPublicKey()!! val recipient = Recipient.from(context, fromSerialized(groupID), false) - val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() ?: "" - val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, true, null, listOf(), listOf()) + val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, 0, true, null, listOf(), listOf()) val mmsDB = DatabaseComponent.get(context).mmsDatabase() val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase() if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return @@ -996,23 +1026,6 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co .updateTimestampUpdated(groupID, updatedTimestamp) } - 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<String>) { return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities) } @@ -1135,11 +1148,10 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } override fun getRecipientSettings(address: Address): Recipient.RecipientSettings? { - val recipientSettings = DatabaseComponent.get(context).recipientDatabase().getRecipientSettings(address) - return if (recipientSettings.isPresent) { recipientSettings.get() } else null + return DatabaseComponent.get(context).recipientDatabase().getRecipientSettings(address).orNull() } - override fun addLibSessionContacts(contacts: List<LibSessionContact>) { + override fun addLibSessionContacts(contacts: List<LibSessionContact>, timestamp: Long) { val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() val moreContacts = contacts.filter { contact -> val id = SessionId(contact.id) @@ -1172,13 +1184,19 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co profileManager.setProfilePicture(context, recipient, null, null) } if (contact.priority == PRIORITY_HIDDEN) { - getThreadId(fromSerialized(contact.id))?.let { conversationThreadId -> - deleteConversation(conversationThreadId) - } + getThreadId(fromSerialized(contact.id))?.let(::deleteConversation) } else { - getThreadId(fromSerialized(contact.id))?.let { conversationThreadId -> - setPinned(conversationThreadId, contact.priority == PRIORITY_PINNED) - } + ( + getThreadId(address) ?: getOrCreateThreadIdFor(address).also { + setThreadDate(it, 0) + } + ).also { setPinned(it, contact.priority == PRIORITY_PINNED) } + } + getThreadId(recipient)?.let { + setExpirationConfiguration( + getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > timestamp } + ?: ExpirationConfiguration(it, contact.expiryMode, timestamp) + ) } setRecipientHash(recipient, contact.hashCode().toString()) } @@ -1293,20 +1311,26 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co threadDb.setDate(threadId, newDate) } + override fun getLastLegacyRecipient(threadRecipient: String): String? = + DatabaseComponent.get(context).lokiAPIDatabase().getLastLegacySenderAddress(threadRecipient) + + override fun setLastLegacyRecipient(threadRecipient: String, senderRecipient: String?) { + DatabaseComponent.get(context).lokiAPIDatabase().setLastLegacySenderAddress(threadRecipient, senderRecipient) + } + 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) { + val recipient = getRecipientForThread(threadID) ?: return + when { + recipient.isContactRecipient -> { if (recipient.isLocalNumber) return val contacts = configFactory.contacts ?: return - contacts.upsertContact(recipient.address.serialize()) { - this.priority = PRIORITY_HIDDEN - } + contacts.upsertContact(recipient.address.serialize()) { priority = PRIORITY_HIDDEN } ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) - } else if (recipient.isClosedGroupRecipient) { + } + recipient.isClosedGroupRecipient -> { // TODO: handle closed group val volatile = configFactory.convoVolatile ?: return val groups = configFactory.userGroups ?: return @@ -1338,14 +1362,17 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co val recipient = Recipient.from(context, address, false) if (recipient.isBlocked) return - val threadId = getThreadId(recipient) ?: return - + val expirationConfig = getExpirationConfiguration(threadId) + val expiryMode = expirationConfig?.expiryMode ?: ExpiryMode.NONE + val expiresInMillis = expiryMode.expiryMillis + val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) sentTimestamp else 0 val mediaMessage = IncomingMediaMessage( address, sentTimestamp, -1, - 0, + expiresInMillis, + expireStartedAt, false, false, false, @@ -1360,6 +1387,8 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co ) database.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) + + SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode) } override fun insertMessageRequestResponse(response: MessageRequestResponse) { @@ -1440,12 +1469,12 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co } recipientDb.setApproved(sender, true) recipientDb.setApprovedMe(sender, true) - val message = IncomingMediaMessage( sender.address, response.sentTimestamp!!, -1, 0, + 0, false, false, true, @@ -1485,8 +1514,15 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) { val database = DatabaseComponent.get(context).smsDatabase() val address = fromSerialized(senderPublicKey) - val callMessage = IncomingTextMessage.fromCallInfo(callMessageType, address, Optional.absent(), sentTimestamp) + val recipient = Recipient.from(context, address, false) + val threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + val expirationConfig = getExpirationConfiguration(threadId) + val expiryMode = expirationConfig?.expiryMode?.coerceSendToRead() ?: ExpiryMode.NONE + val expiresInMillis = expiryMode.expiryMillis + val expireStartedAt = if (expiryMode is ExpiryMode.AfterSend) sentTimestamp else 0 + val callMessage = IncomingTextMessage.fromCallInfo(callMessageType, address, Optional.absent(), sentTimestamp, expiresInMillis, expireStartedAt) database.insertCallMessage(callMessage) + SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode) } override fun conversationHasOutgoing(userPublicKey: String): Boolean { @@ -1623,4 +1659,100 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co val recipientDb = DatabaseComponent.get(context).recipientDatabase() return recipientDb.blockedContacts } + + override fun getExpirationConfiguration(threadId: Long): ExpirationConfiguration? { + val recipient = getRecipientForThread(threadId) ?: return null + val dbExpirationMetadata = DatabaseComponent.get(context).expirationConfigurationDatabase().getExpirationConfiguration(threadId) ?: return null + return when { + recipient.isLocalNumber -> configFactory.user?.getNtsExpiry() + recipient.isContactRecipient -> { + // read it from contacts config if exists + recipient.address.serialize().takeIf { it.startsWith(IdPrefix.STANDARD.value) } + ?.let { configFactory.contacts?.get(it)?.expiryMode } + } + recipient.isClosedGroupRecipient -> { + // read it from group config if exists + GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) + .let { configFactory.userGroups?.getLegacyGroupInfo(it) } + ?.run { disappearingTimer.takeIf { it != 0L }?.let(ExpiryMode::AfterSend) ?: ExpiryMode.NONE } + } + else -> null + }?.let { ExpirationConfiguration(threadId, it, dbExpirationMetadata.updatedTimestampMs) } + } + + override fun setExpirationConfiguration(config: ExpirationConfiguration) { + val recipient = getRecipientForThread(config.threadId) ?: return + + val expirationDb = DatabaseComponent.get(context).expirationConfigurationDatabase() + val currentConfig = expirationDb.getExpirationConfiguration(config.threadId) + if (currentConfig != null && currentConfig.updatedTimestampMs >= config.updatedTimestampMs) return + val expiryMode = config.expiryMode + + if (expiryMode == ExpiryMode.NONE) { + // Clear the legacy recipients on updating config to be none + DatabaseComponent.get(context).lokiAPIDatabase().setLastLegacySenderAddress(recipient.address.serialize(), null) + } + + if (recipient.isClosedGroupRecipient) { + val userGroups = configFactory.userGroups ?: return + val groupPublicKey = GroupUtil.addressToGroupSessionId(recipient.address) + val groupInfo = userGroups.getLegacyGroupInfo(groupPublicKey) + ?.copy(disappearingTimer = expiryMode.expirySeconds) ?: return + userGroups.set(groupInfo) + } else if (recipient.isLocalNumber) { + val user = configFactory.user ?: return + user.setNtsExpiry(expiryMode) + } else if (recipient.isContactRecipient) { + val contacts = configFactory.contacts ?: return + + val contact = contacts.get(recipient.address.serialize())?.copy(expiryMode = expiryMode) ?: return + contacts.set(contact) + } + expirationDb.setExpirationConfiguration( + config.run { copy(expiryMode = expiryMode) } + ) + } + + override fun getExpiringMessages(messageIds: List<Long>): List<Pair<Long, Long>> { + val expiringMessages = mutableListOf<Pair<Long, Long>>() + val smsDb = DatabaseComponent.get(context).smsDatabase() + smsDb.readerFor(smsDb.expirationNotStartedMessages).use { reader -> + while (reader.next != null) { + if (messageIds.isEmpty() || reader.current.id in messageIds) { + expiringMessages.add(reader.current.id to reader.current.expiresIn) + } + } + } + val mmsDb = DatabaseComponent.get(context).mmsDatabase() + mmsDb.expireNotStartedMessages.use { reader -> + while (reader.next != null) { + if (messageIds.isEmpty() || reader.current.id in messageIds) { + expiringMessages.add(reader.current.id to reader.current.expiresIn) + } + } + } + return expiringMessages + } + + override fun updateDisappearingState( + messageSender: String, + threadID: Long, + disappearingState: Recipient.DisappearingState + ) { + val threadDb = DatabaseComponent.get(context).threadDatabase() + val lokiDb = DatabaseComponent.get(context).lokiAPIDatabase() + val recipient = threadDb.getRecipientForThreadId(threadID) ?: return + val recipientAddress = recipient.address.serialize() + DatabaseComponent.get(context).recipientDatabase() + .setDisappearingState(recipient, disappearingState); + val currentLegacyRecipient = lokiDb.getLastLegacySenderAddress(recipientAddress) + val currentExpiry = getExpirationConfiguration(threadID) + if (disappearingState == DisappearingState.LEGACY + && currentExpiry?.isEnabled == true + && ExpirationConfiguration.isNewConfigEnabled) { // only set "this person is legacy" if new config enabled + lokiDb.setLastLegacySenderAddress(recipientAddress, messageSender) + } else if (messageSender == currentLegacyRecipient) { + lokiDb.setLastLegacySenderAddress(recipientAddress, null) + } + } } \ No newline at end of file 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 921b1c06b4..fd5042086c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -51,7 +51,6 @@ import org.session.libsignal.utilities.Pair; import org.session.libsignal.utilities.guava.Optional; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.contacts.ContactUtil; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; @@ -816,13 +815,7 @@ public class ThreadDatabase extends Database { MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && !force) return false; List<MarkedMessageInfo> messages = setRead(threadId, lastSeenTime); - if (isGroupRecipient) { - for (MarkedMessageInfo message: messages) { - MarkReadReceiver.scheduleDeletion(context, message.getExpirationInfo()); - } - } else { - MarkReadReceiver.process(context, messages); - } + MarkReadReceiver.process(context, messages); ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId); return setLastSeen(threadId, lastSeenTime); } 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 1b65706a5c..7713043c2c 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 @@ -21,6 +21,7 @@ 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.ExpirationConfigurationDatabase; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupMemberDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase; @@ -89,9 +90,12 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV41 = 62; private static final int lokiV42 = 63; private static final int lokiV43 = 64; + private static final int lokiV44 = 65; + private static final int lokiV45 = 66; + private static final int lokiV46 = 67; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV43; + private static final int DATABASE_VERSION = lokiV46; 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"; @@ -311,6 +315,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(LokiMessageDatabase.getCreateMessageToThreadMappingTableCommand()); db.execSQL(LokiMessageDatabase.getCreateErrorMessageTableCommand()); db.execSQL(LokiMessageDatabase.getCreateMessageHashTableCommand()); + db.execSQL(LokiMessageDatabase.getCreateSmsHashTableCommand()); + db.execSQL(LokiMessageDatabase.getCreateMmsHashTableCommand()); db.execSQL(LokiThreadDatabase.getCreateSessionResetTableCommand()); db.execSQL(LokiThreadDatabase.getCreatePublicChatTableCommand()); db.execSQL(LokiUserDatabase.getCreateDisplayNameTableCommand()); @@ -324,6 +330,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(GroupDatabase.getCreateUpdatedTimestampCommand()); db.execSQL(RecipientDatabase.getCreateApprovedCommand()); db.execSQL(RecipientDatabase.getCreateApprovedMeCommand()); + db.execSQL(RecipientDatabase.getCreateDisappearingStateCommand()); db.execSQL(MmsDatabase.CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND); db.execSQL(MmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND); db.execSQL(SmsDatabase.CREATE_REACTIONS_UNREAD_COMMAND); @@ -345,6 +352,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(SmsDatabase.CREATE_HAS_MENTION_COMMAND); db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND); db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND); + db.execSQL(ExpirationConfigurationDatabase.CREATE_EXPIRATION_CONFIGURATION_TABLE_COMMAND); executeStatements(db, SmsDatabase.CREATE_INDEXS); executeStatements(db, MmsDatabase.CREATE_INDEXS); @@ -358,6 +366,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS); db.execSQL(RecipientDatabase.getAddWrapperHash()); db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests()); + db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE); } @Override @@ -604,6 +613,26 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests()); } + if (oldVersion < lokiV44) { + db.execSQL(SessionJobDatabase.dropAttachmentDownloadJobs); + } + + if (oldVersion < lokiV45) { + db.execSQL(RecipientDatabase.getCreateDisappearingStateCommand()); + db.execSQL(ExpirationConfigurationDatabase.CREATE_EXPIRATION_CONFIGURATION_TABLE_COMMAND); + db.execSQL(ExpirationConfigurationDatabase.MIGRATE_GROUP_CONVERSATION_EXPIRY_TYPE_COMMAND); + db.execSQL(ExpirationConfigurationDatabase.MIGRATE_ONE_TO_ONE_CONVERSATION_EXPIRY_TYPE_COMMAND); + + db.execSQL(LokiMessageDatabase.getCreateSmsHashTableCommand()); + db.execSQL(LokiMessageDatabase.getCreateMmsHashTableCommand()); + } + + if (oldVersion < lokiV46) { + executeStatements(db, SmsDatabase.ADD_AUTOINCREMENT); + executeStatements(db, MmsDatabase.ADD_AUTOINCREMENT); + db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index ba01ffd9c5..b5b0aea20c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -54,6 +54,10 @@ public abstract class MessageRecord extends DisplayRecord { private final List<ReactionRecord> reactions; private final boolean hasMention; + public final boolean isNotDisappearAfterRead() { + return expireStarted == getTimestamp(); + } + public abstract boolean isMms(); public abstract boolean isMmsNotification(); @@ -116,7 +120,7 @@ public abstract class MessageRecord extends DisplayRecord { return new SpannableString(UpdateMessageBuilder.INSTANCE.buildGroupUpdateMessage(context, updateMessageData, getIndividualRecipient().getAddress().serialize(), isOutgoing())); } else if (isExpirationTimerUpdate()) { int seconds = (int) (getExpiresIn() / 1000); - return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, getIndividualRecipient().getAddress().serialize(), isOutgoing())); + return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, getRecipient(), getIndividualRecipient().getAddress().serialize(), isOutgoing(), getTimestamp(), expireStarted)); } else if (isDataExtractionNotification()) { if (isScreenshotNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.SCREENSHOT, getIndividualRecipient().getAddress().serialize()))); else if (isMediaSavedNotification()) return new SpannableString((UpdateMessageBuilder.INSTANCE.buildDataExtractionMessage(context, DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED, getIndividualRecipient().getAddress().serialize()))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index d664ffedb2..8379e1a23b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -187,7 +187,7 @@ class ConfigFactory( override fun persist(forConfigObject: ConfigBase, timestamp: Long) { try { listeners.forEach { listener -> - listener.notifyUpdates(forConfigObject) + listener.notifyUpdates(forConfigObject, timestamp) } when (forConfigObject) { is UserProfile -> persistUserConfigDump(timestamp) 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 f2c046e0aa..c037f3b27a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt @@ -7,6 +7,7 @@ import dagger.hilt.components.SingletonComponent import org.session.libsession.database.MessageDataProvider import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.database.* +import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper @EntryPoint @@ -45,5 +46,6 @@ interface DatabaseComponent { fun attachmentProvider(): MessageDataProvider fun blindedIdMappingDatabase(): BlindedIdMappingDatabase fun groupMemberDatabase(): GroupMemberDatabase + fun expirationConfigurationDatabase(): ExpirationConfigurationDatabase 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 524100190e..30fb40d89a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt @@ -7,12 +7,14 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import org.session.libsession.database.MessageDataProvider +import org.session.libsession.utilities.SSKEnvironment import org.thoughtcrime.securesms.attachments.DatabaseAttachmentProvider import org.thoughtcrime.securesms.crypto.AttachmentSecret import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider import org.thoughtcrime.securesms.database.* import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.service.ExpiringMessageManager import javax.inject.Singleton @Module @@ -24,6 +26,10 @@ object DatabaseModule { System.loadLibrary("sqlcipher") } + @Provides + @Singleton + fun provideMessageExpirationManagerProtocol(@ApplicationContext context: Context): SSKEnvironment.MessageExpirationManagerProtocol = ExpiringMessageManager(context) + @Provides @Singleton fun provideAttachmentSecret(@ApplicationContext context: Context) = AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret @@ -129,6 +135,10 @@ object DatabaseModule { @Singleton fun provideEmojiSearchDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = EmojiSearchDatabase(context, openHelper) + @Provides + @Singleton + fun provideExpirationConfigurationDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = ExpirationConfigurationDatabase(context, openHelper) + @Provides @Singleton fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt index f8e64dd381..adeeeb91fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt @@ -8,7 +8,6 @@ import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPol 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 @@ -41,7 +40,7 @@ object ClosedGroupManager { return groups.eraseLegacyGroup(groupPublicKey) } - fun ConfigFactory.updateLegacyGroup(groupRecipientSettings: Recipient.RecipientSettings, group: GroupRecord) { + fun ConfigFactory.updateLegacyGroup(group: GroupRecord) { val groups = userGroups ?: return if (!group.isClosedGroup) return val storage = MessagingModuleConfiguration.shared.storage @@ -53,7 +52,6 @@ object ClosedGroupManager { 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() 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 9fee8adafc..da982589c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt @@ -176,6 +176,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { // endregion // region Updating + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) when (requestCode) { @@ -335,7 +336,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { ?: 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) + groupConfigFactory.updateLegacyGroup(latestGroup) } class GroupMembers(val members: List<String>, val zombieMembers: List<String>) 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 31b281c6de..c876edb822 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt @@ -92,7 +92,7 @@ class ConversationView : LinearLayout { val senderDisplayName = getUserDisplayName(thread.recipient) ?: thread.recipient.address.toString() binding.conversationViewDisplayNameTextView.text = senderDisplayName - binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date) + binding.timestampTextView.text = thread.date.takeIf { it != 0L }?.let { DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), it) } val recipient = thread.recipient binding.muteIndicatorImageView.isVisible = recipient.isMuted || recipient.notifyType != NOTIFY_TYPE_ALL val drawableRes = if (recipient.isMuted || recipient.notifyType == NOTIFY_TYPE_NONE) { 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 ba519f0a79..850f065728 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -8,6 +8,7 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.os.Build import android.os.Bundle import android.text.SpannableString import android.widget.Toast @@ -300,7 +301,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), EventBus.getDefault().register(this@HomeActivity) if (intent.hasExtra(FROM_ONBOARDING) && intent.getBooleanExtra(FROM_ONBOARDING, false)) { - if ((getSystemService(NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled().not()) { + if (Build.VERSION.SDK_INT >= 33 && + (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled().not()) { Permissions.with(this) .request(Manifest.permission.POST_NOTIFICATIONS) .execute() @@ -453,6 +455,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // endregion // region Interaction + @Deprecated("Deprecated in Java") override fun onBackPressed() { if (binding.globalSearchRecycler.isVisible) { binding.globalSearchInputLayout.clearSearch(true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessage.java b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessage.java deleted file mode 100644 index 7369405d11..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessage.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.thoughtcrime.securesms.longmessage; - -import android.text.TextUtils; - -import org.thoughtcrime.securesms.database.model.MessageRecord; - -/** - * A wrapper around a {@link MessageRecord} and its extra text attachment expanded into a string - * held in memory. - */ -class LongMessage { - - private final MessageRecord messageRecord; - private final String fullBody; - - LongMessage(MessageRecord messageRecord, String fullBody) { - this.messageRecord = messageRecord; - this.fullBody = fullBody; - } - - MessageRecord getMessageRecord() { - return messageRecord; - } - - String getFullBody() { - return !TextUtils.isEmpty(fullBody) ? fullBody : messageRecord.getBody(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java deleted file mode 100644 index 68c568bb33..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java +++ /dev/null @@ -1,91 +0,0 @@ -package org.thoughtcrime.securesms.longmessage; - -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.text.Spannable; -import android.text.method.LinkMovementMethod; -import android.view.MenuItem; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.lifecycle.ViewModelProvider; - -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.recipients.Recipient; -import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; -import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentView; - -import network.loki.messenger.R; - -public class LongMessageActivity extends PassphraseRequiredActionBarActivity { - - private static final String KEY_ADDRESS = "address"; - private static final String KEY_MESSAGE_ID = "message_id"; - private static final String KEY_IS_MMS = "is_mms"; - - private static final int MAX_DISPLAY_LENGTH = 64 * 1024; - - private TextView textBody; - - private LongMessageViewModel viewModel; - - public static Intent getIntent(@NonNull Context context, @NonNull Address conversationAddress, long messageId, boolean isMms) { - Intent intent = new Intent(context, LongMessageActivity.class); - intent.putExtra(KEY_ADDRESS, conversationAddress.serialize()); - intent.putExtra(KEY_MESSAGE_ID, messageId); - intent.putExtra(KEY_IS_MMS, isMms); - return intent; - } - - @Override - protected void onCreate(Bundle savedInstanceState, boolean ready) { - super.onCreate(savedInstanceState, ready); - setContentView(R.layout.longmessage_activity); - textBody = findViewById(R.id.longmessage_text); - - initViewModel(getIntent().getLongExtra(KEY_MESSAGE_ID, -1), getIntent().getBooleanExtra(KEY_IS_MMS, false)); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - super.onOptionsItemSelected(item); - - switch (item.getItemId()) { - case android.R.id.home: - finish(); - return true; - } - - return false; - } - - private void initViewModel(long messageId, boolean isMms) { - viewModel = new ViewModelProvider(this, new LongMessageViewModel.Factory(getApplication(), new LongMessageRepository(this), messageId, isMms)) - .get(LongMessageViewModel.class); - - viewModel.getMessage().observe(this, message -> { - if (message == null) return; - - if (!message.isPresent()) { - Toast.makeText(this, R.string.LongMessageActivity_unable_to_find_message, Toast.LENGTH_SHORT).show(); - finish(); - return; - } - - if (message.get().getMessageRecord().isOutgoing()) { - getSupportActionBar().setTitle(getString(R.string.LongMessageActivity_your_message)); - } else { - Recipient recipient = message.get().getMessageRecord().getRecipient(); - String name = Util.getFirstNonEmpty(recipient.getName(), recipient.getProfileName(), recipient.getAddress().serialize()); - getSupportActionBar().setTitle(getString(R.string.LongMessageActivity_message_from_s, name)); - } - Spannable bodySpans = VisibleMessageContentView.Companion.getBodySpans(this, message.get().getMessageRecord(), null); - textBody.setText(bodySpans); - textBody.setMovementMethod(LinkMovementMethod.getInstance()); - }); - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageRepository.java b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageRepository.java deleted file mode 100644 index 4f3e1e6ec3..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageRepository.java +++ /dev/null @@ -1,102 +0,0 @@ -package org.thoughtcrime.securesms.longmessage; - -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.WorkerThread; - -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.concurrent.SignalExecutors; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.database.MmsDatabase; -import org.thoughtcrime.securesms.database.SmsDatabase; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.database.model.MmsMessageRecord; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.mms.PartAuthority; -import org.thoughtcrime.securesms.mms.TextSlide; - -import java.io.IOException; -import java.io.InputStream; - -class LongMessageRepository { - - private final static String TAG = LongMessageRepository.class.getSimpleName(); - - private final MmsDatabase mmsDatabase; - private final SmsDatabase smsDatabase; - - LongMessageRepository(@NonNull Context context) { - this.mmsDatabase = DatabaseComponent.get(context).mmsDatabase(); - this.smsDatabase = DatabaseComponent.get(context).smsDatabase(); - } - - void getMessage(@NonNull Context context, long messageId, boolean isMms, @NonNull Callback<Optional<LongMessage>> callback) { - SignalExecutors.BOUNDED.execute(() -> { - if (isMms) { - callback.onComplete(getMmsLongMessage(context, mmsDatabase, messageId)); - } else { - callback.onComplete(getSmsLongMessage(smsDatabase, messageId)); - } - }); - } - - @WorkerThread - private Optional<LongMessage> getMmsLongMessage(@NonNull Context context, @NonNull MmsDatabase mmsDatabase, long messageId) { - Optional<MmsMessageRecord> record = getMmsMessage(mmsDatabase, messageId); - - if (record.isPresent()) { - TextSlide textSlide = record.get().getSlideDeck().getTextSlide(); - - if (textSlide != null && textSlide.getUri() != null) { - return Optional.of(new LongMessage(record.get(), readFullBody(context, textSlide.getUri()))); - } else { - return Optional.of(new LongMessage(record.get(), "")); - } - } else { - return Optional.absent(); - } - } - - @WorkerThread - private Optional<LongMessage> getSmsLongMessage(@NonNull SmsDatabase smsDatabase, long messageId) { - Optional<MessageRecord> record = getSmsMessage(smsDatabase, messageId); - - if (record.isPresent()) { - return Optional.of(new LongMessage(record.get(), "")); - } else { - return Optional.absent(); - } - } - - - @WorkerThread - private Optional<MmsMessageRecord> getMmsMessage(@NonNull MmsDatabase mmsDatabase, long messageId) { - try (Cursor cursor = mmsDatabase.getMessage(messageId)) { - return Optional.fromNullable((MmsMessageRecord) mmsDatabase.readerFor(cursor).getNext()); - } - } - - @WorkerThread - private Optional<MessageRecord> getSmsMessage(@NonNull SmsDatabase smsDatabase, long messageId) { - try (Cursor cursor = smsDatabase.getMessageCursor(messageId)) { - return Optional.fromNullable(smsDatabase.readerFor(cursor).getNext()); - } - } - - private String readFullBody(@NonNull Context context, @NonNull Uri uri) { - try (InputStream stream = PartAuthority.getAttachmentStream(context, uri)) { - return Util.readFullyAsString(stream); - } catch (IOException e) { - Log.w(TAG, "Failed to read full text body.", e); - return ""; - } - } - - interface Callback<T> { - void onComplete(T result); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageViewModel.java deleted file mode 100644 index 27495a492a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageViewModel.java +++ /dev/null @@ -1,84 +0,0 @@ -package org.thoughtcrime.securesms.longmessage; - -import android.app.Application; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; - -import android.database.ContentObserver; -import android.net.Uri; -import android.os.Handler; -import androidx.annotation.NonNull; - -import org.thoughtcrime.securesms.database.DatabaseContentProviders; -import org.session.libsignal.utilities.guava.Optional; - -class LongMessageViewModel extends ViewModel { - - private final Application application; - private final LongMessageRepository repository; - private final long messageId; - private final boolean isMms; - - private final MutableLiveData<Optional<LongMessage>> message; - private final MessageObserver messageObserver; - - private LongMessageViewModel(@NonNull Application application, @NonNull LongMessageRepository repository, long messageId, boolean isMms) { - this.application = application; - this.repository = repository; - this.messageId = messageId; - this.isMms = isMms; - this.message = new MutableLiveData<>(); - this.messageObserver = new MessageObserver(new Handler()); - - repository.getMessage(application, messageId, isMms, longMessage -> { - if (longMessage.isPresent()) { - Uri uri = DatabaseContentProviders.Conversation.getUriForThread(longMessage.get().getMessageRecord().getThreadId()); - application.getContentResolver().registerContentObserver(uri, true, messageObserver); - } - - message.postValue(longMessage); - }); - } - - LiveData<Optional<LongMessage>> getMessage() { - return message; - } - - @Override - protected void onCleared() { - application.getContentResolver().unregisterContentObserver(messageObserver); - } - - private class MessageObserver extends ContentObserver { - MessageObserver(Handler handler) { - super(handler); - } - - @Override - public void onChange(boolean selfChange) { - repository.getMessage(application, messageId, isMms, message::postValue); - } - } - - static class Factory extends ViewModelProvider.NewInstanceFactory { - - private final Application context; - private final LongMessageRepository repository; - private final long messageId; - private final boolean isMms; - - public Factory(@NonNull Application application, @NonNull LongMessageRepository repository, long messageId, boolean isMms) { - this.context = application; - this.repository = repository; - this.messageId = messageId; - this.isMms = isMms; - } - - @Override - public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) { - return modelClass.cast(new LongMessageViewModel(context, repository, messageId, isMms)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java index 21157d0f51..88f92ecb48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java @@ -27,7 +27,7 @@ import androidx.core.app.NotificationManagerCompat; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.MarkedMessageInfo; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import java.util.LinkedList; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java index ddff9f52b6..0bfa2b0899 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java @@ -26,6 +26,7 @@ import android.os.Bundle; import androidx.core.app.RemoteInput; +import org.session.libsession.messaging.messages.ExpirationConfiguration; import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage; import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; import org.session.libsession.messaging.messages.visible.VisibleMessage; @@ -35,13 +36,15 @@ import org.session.libsession.utilities.Address; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.MarkedMessageInfo; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.mms.MmsException; import java.util.Collections; import java.util.List; +import network.loki.messenger.libsession_util.util.ExpiryMode; + /** * Get the response text from the Android Auto and sends an message as a reply */ @@ -85,10 +88,14 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver { message.setText(responseText.toString()); message.setSentTimestamp(SnodeAPI.getNowWithOffset()); MessageSender.send(message, recipient.getAddress()); + ExpirationConfiguration config = DatabaseComponent.get(context).storage().getExpirationConfiguration(threadId); + ExpiryMode expiryMode = config == null ? null : config.getExpiryMode(); + long expiresInMillis = expiryMode == null ? 0 : expiryMode.getExpiryMillis(); + long expireStartedAt = expiryMode instanceof ExpiryMode.AfterSend ? message.getSentTimestamp() : 0L; if (recipient.isGroupRecipient()) { Log.w("AndroidAutoReplyReceiver", "GroupRecipient, Sending media message"); - OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null); + OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null, expiresInMillis, 0); try { DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, replyThreadId, false, null, true); } catch (MmsException e) { @@ -96,7 +103,7 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver { } } else { Log.w("AndroidAutoReplyReceiver", "Sending regular message "); - OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient); + OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt); DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(replyThreadId, reply, false, SnodeAPI.getNowWithOffset(), null, true); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java deleted file mode 100644 index 309f2732f8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java +++ /dev/null @@ -1,100 +0,0 @@ -package org.thoughtcrime.securesms.notifications; - -import android.annotation.SuppressLint; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.AsyncTask; - -import androidx.annotation.NonNull; -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; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.database.MessagingDatabase.ExpirationInfo; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; -import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.service.ExpiringMessageManager; -import org.thoughtcrime.securesms.util.SessionMetaProtocol; - -import java.util.List; -import java.util.Map; - -public class MarkReadReceiver extends BroadcastReceiver { - - private static final String TAG = MarkReadReceiver.class.getSimpleName(); - public static final String CLEAR_ACTION = "network.loki.securesms.notifications.CLEAR"; - public static final String THREAD_IDS_EXTRA = "thread_ids"; - public static final String NOTIFICATION_ID_EXTRA = "notification_id"; - - @SuppressLint("StaticFieldLeak") - @Override - public void onReceive(final Context context, Intent intent) { - if (!CLEAR_ACTION.equals(intent.getAction())) - return; - - final long[] threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA); - - if (threadIds != null) { - NotificationManagerCompat.from(context).cancel(intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)); - - new AsyncTask<Void, Void, Void>() { - @Override - protected Void doInBackground(Void... params) { - long currentTime = SnodeAPI.getNowWithOffset(); - for (long threadId : threadIds) { - Log.i(TAG, "Marking as read: " + threadId); - StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage(); - storage.markConversationAsRead(threadId,currentTime, true); - } - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - } - - public static void process(@NonNull Context context, @NonNull List<MarkedMessageInfo> markedReadMessages) { - if (markedReadMessages.isEmpty()) return; - - for (MarkedMessageInfo messageInfo : markedReadMessages) { - scheduleDeletion(context, messageInfo.getExpirationInfo()); - } - - if (!TextSecurePreferences.isReadReceiptsEnabled(context)) return; - - Map<Address, List<SyncMessageId>> addressMap = Stream.of(markedReadMessages) - .map(MarkedMessageInfo::getSyncMessageId) - .collect(Collectors.groupingBy(SyncMessageId::getAddress)); - - for (Address address : addressMap.keySet()) { - List<Long> timestamps = Stream.of(addressMap.get(address)).map(SyncMessageId::getTimetamp).toList(); - if (!SessionMetaProtocol.shouldSendReadReceipt(Recipient.from(context, address, false))) { continue; } - ReadReceipt readReceipt = new ReadReceipt(timestamps); - readReceipt.setSentTimestamp(SnodeAPI.getNowWithOffset()); - MessageSender.send(readReceipt, address); - } - } - - public static void scheduleDeletion(Context context, ExpirationInfo expirationInfo) { - if (expirationInfo.getExpiresIn() > 0 && expirationInfo.getExpireStarted() <= 0) { - ExpiringMessageManager expirationManager = ApplicationContext.getInstance(context).getExpiringMessageManager(); - - if (expirationInfo.isMms()) DatabaseComponent.get(context).mmsDatabase().markExpireStarted(expirationInfo.getId()); - else DatabaseComponent.get(context).smsDatabase().markExpireStarted(expirationInfo.getId()); - - expirationManager.scheduleDeletion(expirationInfo.getId(), expirationInfo.isMms(), expirationInfo.getExpiresIn()); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt new file mode 100644 index 0000000000..9f83726f46 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -0,0 +1,157 @@ +package org.thoughtcrime.securesms.notifications + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.AsyncTask +import androidx.core.app.NotificationManagerCompat +import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.sending_receiving.MessageSender.send +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled +import org.session.libsession.utilities.associateByNotNull +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.database.ExpirationInfo +import org.thoughtcrime.securesms.database.MarkedMessageInfo +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.util.SessionMetaProtocol.shouldSendReadReceipt + +class MarkReadReceiver : BroadcastReceiver() { + @SuppressLint("StaticFieldLeak") + override fun onReceive(context: Context, intent: Intent) { + if (CLEAR_ACTION != intent.action) return + val threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA) ?: return + NotificationManagerCompat.from(context).cancel(intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)) + object : AsyncTask<Void?, Void?, Void?>() { + override fun doInBackground(vararg params: Void?): Void? { + val currentTime = nowWithOffset + threadIds.forEach { + Log.i(TAG, "Marking as read: $it") + shared.storage.markConversationAsRead(it, currentTime, true) + } + return null + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } + + companion object { + private val TAG = MarkReadReceiver::class.java.simpleName + const val CLEAR_ACTION = "network.loki.securesms.notifications.CLEAR" + const val THREAD_IDS_EXTRA = "thread_ids" + const val NOTIFICATION_ID_EXTRA = "notification_id" + + val messageExpirationManager = SSKEnvironment.shared.messageExpirationManager + + @JvmStatic + fun process( + context: Context, + markedReadMessages: List<MarkedMessageInfo> + ) { + if (markedReadMessages.isEmpty()) return + + sendReadReceipts(context, markedReadMessages) + + val mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase() + + // start disappear after read messages except TimerUpdates in groups. + markedReadMessages + .filter { it.expiryType == ExpiryType.AFTER_READ } + .map { it.syncMessageId } + .filter { mmsSmsDatabase.getMessageForTimestamp(it.timetamp)?.run { isExpirationTimerUpdate && recipient.isClosedGroupRecipient } == false } + .forEach { messageExpirationManager.startDisappearAfterRead(it.timetamp, it.address.serialize()) } + + hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { + fetchUpdatedExpiriesAndScheduleDeletion(context, it) + shortenExpiryOfDisappearingAfterRead(context, it) + } + } + + private fun hashToDisappearAfterReadMessage( + context: Context, + markedReadMessages: List<MarkedMessageInfo> + ): Map<String, MarkedMessageInfo>? { + val loki = DatabaseComponent.get(context).lokiMessageDatabase() + + return markedReadMessages + .filter { it.expiryType == ExpiryType.AFTER_READ } + .associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id, isMms) } } + .takeIf { it.isNotEmpty() } + } + + private fun shortenExpiryOfDisappearingAfterRead( + context: Context, + hashToMessage: Map<String, MarkedMessageInfo> + ) { + hashToMessage.entries + .groupBy( + keySelector = { it.value.expirationInfo.expiresIn }, + valueTransform = { it.key } + ).forEach { (expiresIn, hashes) -> + SnodeAPI.alterTtl( + messageHashes = hashes, + newExpiry = nowWithOffset + expiresIn, + publicKey = TextSecurePreferences.getLocalNumber(context)!!, + shorten = true + ) + } + } + + private fun sendReadReceipts( + context: Context, + markedReadMessages: List<MarkedMessageInfo> + ) { + if (!isReadReceiptsEnabled(context)) return + + markedReadMessages.map { it.syncMessageId } + .filter { shouldSendReadReceipt(Recipient.from(context, it.address, false)) } + .groupBy { it.address } + .forEach { (address, messages) -> + messages.map { it.timetamp } + .let(::ReadReceipt) + .apply { sentTimestamp = nowWithOffset } + .let { send(it, address) } + } + } + + private fun fetchUpdatedExpiriesAndScheduleDeletion( + context: Context, + hashToMessage: Map<String, MarkedMessageInfo> + ) { + @Suppress("UNCHECKED_CAST") + val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), TextSecurePreferences.getLocalNumber(context)!!).get()["expiries"] as Map<String, Long> + hashToMessage.forEach { (hash, info) -> expiries[hash]?.let { scheduleDeletion(context, info.expirationInfo, it - info.expirationInfo.expireStarted) } } + } + + private fun scheduleDeletion( + context: Context?, + expirationInfo: ExpirationInfo, + expiresIn: Long = expirationInfo.expiresIn + ) { + if (expiresIn == 0L) return + + val now = nowWithOffset + + val expireStarted = expirationInfo.expireStarted + + if (expirationInfo.isDisappearAfterRead() && expireStarted == 0L || now < expireStarted) { + val db = DatabaseComponent.get(context!!).run { if (expirationInfo.isMms) mmsDatabase() else smsDatabase() } + db.markExpireStarted(expirationInfo.id, now) + } + + ApplicationContext.getInstance(context).expiringMessageManager.scheduleDeletion( + expirationInfo.id, + expirationInfo.isMms, + now, + expiresIn + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt index 3982113985..7fb9c12ab4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt @@ -10,6 +10,7 @@ import com.goterl.lazysodium.utils.Key import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonBuilder import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveParameters @@ -28,6 +29,7 @@ private const val TAG = "PushHandler" class PushReceiver @Inject constructor(@ApplicationContext val context: Context) { private val sodium = LazySodiumAndroid(SodiumAndroid()) + private val json = Json { ignoreUnknownKeys = true } fun onPush(dataMap: Map<String, String>?) { onPush(dataMap?.asByteArray()) @@ -89,7 +91,7 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context) ?: error("Failed to decode bencoded list from payload") val metadataJson = (expectedList[0] as? BencodeString)?.value ?: error("no metadata") - val metadata: PushNotificationMetadata = Json.decodeFromString(String(metadataJson)) + val metadata: PushNotificationMetadata = json.decodeFromString(String(metadataJson)) return (expectedList.getOrNull(1) as? BencodeString)?.value.also { // null content is valid only if we got a "data_too_long" flag diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index 06ea38b79b..cf0e04ddf4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -26,25 +26,35 @@ import android.os.Bundle; import androidx.core.app.RemoteInput; +import org.session.libsession.messaging.messages.ExpirationConfiguration; import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage; import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; import org.session.libsession.messaging.messages.visible.VisibleMessage; 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.recipients.Recipient; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.Storage; import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.mms.MmsException; import java.util.Collections; import java.util.List; +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; +import network.loki.messenger.libsession_util.util.ExpiryMode; + /** * Get the response text from the Wearable Device and sends an message as a reply */ +@AndroidEntryPoint public class RemoteReplyReceiver extends BroadcastReceiver { public static final String TAG = RemoteReplyReceiver.class.getSimpleName(); @@ -52,6 +62,15 @@ public class RemoteReplyReceiver extends BroadcastReceiver { public static final String ADDRESS_EXTRA = "address"; public static final String REPLY_METHOD = "reply_method"; + @Inject + ThreadDatabase threadDatabase; + @Inject + MmsDatabase mmsDatabase; + @Inject + SmsDatabase smsDatabase; + @Inject + Storage storage; + @SuppressLint("StaticFieldLeak") @Override public void onReceive(final Context context, Intent intent) { @@ -73,17 +92,20 @@ public class RemoteReplyReceiver extends BroadcastReceiver { @Override protected Void doInBackground(Void... params) { Recipient recipient = Recipient.from(context, address, false); - ThreadDatabase threadDatabase = DatabaseComponent.get(context).threadDatabase(); long threadId = threadDatabase.getOrCreateThreadIdFor(recipient); VisibleMessage message = new VisibleMessage(); - message.setSentTimestamp(System.currentTimeMillis()); + message.setSentTimestamp(SnodeAPI.getNowWithOffset()); message.setText(responseText.toString()); + ExpirationConfiguration config = storage.getExpirationConfiguration(threadId); + ExpiryMode expiryMode = config == null ? null : config.getExpiryMode(); + long expiresInMillis = expiryMode == null ? 0 : expiryMode.getExpiryMillis(); + long expireStartedAt = expiryMode instanceof ExpiryMode.AfterSend ? message.getSentTimestamp() : 0L; switch (replyMethod) { case GroupMessage: { - OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null); + OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null, expiresInMillis, 0); try { - DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, threadId, false, null, true); + mmsDatabase.insertMessageOutbox(reply, threadId, false, null, true); MessageSender.send(message, address); } catch (MmsException e) { Log.w(TAG, e); @@ -91,8 +113,8 @@ public class RemoteReplyReceiver extends BroadcastReceiver { break; } case SecureMessage: { - OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient); - DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), null, true); + OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt); + smsDatabase.insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), null, true); MessageSender.send(message, address); break; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt index f67f0fbaa6..c878a79eef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt @@ -14,6 +14,11 @@ class LandingActivity : BaseActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // We always hit this LandingActivity on launch - but if there is a previous instance of + // Session then close this activity to resume the last activity from the previous instance. + if (!isTaskRoot) { finish(); return } + val binding = ActivityLandingBinding.inflate(layoutInflater) setContentView(binding.root) setUpActionBarSessionLogo(true) 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 edd1bc274a..1c10571dbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt @@ -54,6 +54,7 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel private val adapter = LinkDeviceActivityAdapter(this) private var restoreJob: Job? = null + @Deprecated("Deprecated in Java") override fun onBackPressed() { if (restoreJob?.isActive == true) return // Don't allow going back with a pending job super.onBackPressed() 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 6e082e0008..13e5b51f0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt @@ -35,6 +35,8 @@ import javax.inject.Inject @AndroidEntryPoint class RegisterActivity : BaseActionBarActivity() { + private val temporarySeedKey = "TEMPORARY_SEED_KEY" + @Inject lateinit var configFactory: ConfigFactory @@ -77,16 +79,23 @@ class RegisterActivity : BaseActionBarActivity() { }, 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) binding.termsTextView.movementMethod = LinkMovementMethod.getInstance() binding.termsTextView.text = termsExplanation - updateKeyPair() + updateKeyPair(savedInstanceState?.getByteArray(temporarySeedKey)) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + seed?.let { tempSeed -> + outState.putByteArray(temporarySeedKey, tempSeed) + } } // endregion // region Updating - private fun updateKeyPair() { - val keyPairGenerationResult = KeyPairUtilities.generate() - seed = keyPairGenerationResult.seed + private fun updateKeyPair(temporaryKey: ByteArray?) { + val keyPairGenerationResult = temporaryKey?.let(KeyPairUtilities::generate) ?: KeyPairUtilities.generate() + seed = keyPairGenerationResult.seed ed25519KeyPair = keyPairGenerationResult.ed25519KeyPair - x25519KeyPair = keyPairGenerationResult.x25519KeyPair + x25519KeyPair = keyPairGenerationResult.x25519KeyPair } private fun updatePublicKeyTextView() { @@ -125,7 +134,6 @@ class RegisterActivity : BaseActionBarActivity() { // which can result in an invalid database state database.clearAllLastMessageHashes() database.clearReceivedMessageHashValues() - KeyPairUtilities.store(this, seed!!, ed25519KeyPair!!, x25519KeyPair!!) configFactory.keyPairChanged() val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey 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 37a54a4afc..31f2782f8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt @@ -15,10 +15,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.DialogClearAllDataBinding +import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.snode.SnodeAPI import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.createSessionDialog +import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities class ClearAllDataDialog : DialogFragment() { @@ -44,9 +46,9 @@ class ClearAllDataDialog : DialogFragment() { 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)) - var selectedOption = device + val device = radioOption("deviceOnly", R.string.dialog_clear_all_data_clear_device_only) + val network = radioOption("deviceAndNetwork", R.string.dialog_clear_all_data_clear_device_and_network) + var selectedOption: RadioOption<String> = device val optionAdapter = RadioOptionAdapter { selectedOption = it } binding.recyclerView.apply { itemAnimator = null @@ -115,6 +117,10 @@ class ClearAllDataDialog : DialogFragment() { } else { // finish val result = try { + val openGroups = DatabaseComponent.get(requireContext()).lokiThreadDatabase().getAllOpenGroups() + openGroups.map { it.value.server }.toSet().forEach { server -> + OpenGroupApi.deleteAllInboxMessages(server).get() + } SnodeAPI.deleteAllMessages().get() } catch (e: Exception) { null diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt index a04e71ddf1..21b12496bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt @@ -95,6 +95,7 @@ class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() { } } + @Deprecated("Deprecated in Java") override fun onRequestPermissionsResult( requestCode: Int, permissions: Array<String>, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt index 4bb69c4c14..f80acee64e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt @@ -3,53 +3,120 @@ package org.thoughtcrime.securesms.preferences import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import network.loki.messenger.R import network.loki.messenger.databinding.ItemSelectableBinding +import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.ui.GetString +import java.util.Objects -class RadioOptionAdapter( - var selectedOptionPosition: Int = 0, - private val onClickListener: (RadioOption) -> Unit -) : ListAdapter<RadioOption, RadioOptionAdapter.ViewHolder>(RadioOptionDiffer()) { +class RadioOptionAdapter<T>( + private var selectedOptionPosition: Int = 0, + private val onClickListener: (RadioOption<T>) -> Unit +) : ListAdapter<RadioOption<T>, RadioOptionAdapter.ViewHolder<T>>(RadioOptionDiffer()) { - class RadioOptionDiffer: DiffUtil.ItemCallback<RadioOption>() { - override fun areItemsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.title == newItem.title - override fun areContentsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.value == newItem.value + class RadioOptionDiffer<T>: DiffUtil.ItemCallback<RadioOption<T>>() { + override fun areItemsTheSame(oldItem: RadioOption<T>, newItem: RadioOption<T>) = oldItem.title == newItem.title + override fun areContentsTheSame(oldItem: RadioOption<T>, newItem: RadioOption<T>) = Objects.equals(oldItem.value,newItem.value) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_selectable, parent, false) - return ViewHolder(itemView) - } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<T> = + LayoutInflater.from(parent.context).inflate(R.layout.item_selectable, parent, false) + .let(::ViewHolder) - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val option = getItem(position) - val isSelected = position == selectedOptionPosition - holder.bind(option, isSelected) { + override fun onBindViewHolder(holder: ViewHolder<T>, position: Int) { + holder.bind( + option = getItem(position), + isSelected = position == selectedOptionPosition + ) { onClickListener(it) - selectedOptionPosition = position - notifyItemRangeChanged(0, itemCount) + setSelectedPosition(position) } } - class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { + fun setSelectedPosition(selectedPosition: Int) { + notifyItemChanged(selectedOptionPosition) + selectedOptionPosition = selectedPosition + notifyItemChanged(selectedOptionPosition) + } + class ViewHolder<T>(itemView: View): RecyclerView.ViewHolder(itemView) { val glide = GlideApp.with(itemView) val binding = ItemSelectableBinding.bind(itemView) - fun bind(option: RadioOption, isSelected: Boolean, toggleSelection: (RadioOption) -> Unit) { - binding.titleTextView.text = option.title - binding.root.setOnClickListener { toggleSelection(option) } + fun bind(option: RadioOption<T>, isSelected: Boolean, toggleSelection: (RadioOption<T>) -> Unit) { + val alpha = if (option.enabled) 1f else 0.5f + binding.root.isEnabled = option.enabled + binding.root.contentDescription = option.contentDescription?.string(itemView.context) + binding.titleTextView.alpha = alpha + binding.subtitleTextView.alpha = alpha + binding.selectButton.alpha = alpha + + binding.titleTextView.text = option.title.string(itemView.context) + binding.subtitleTextView.text = option.subtitle?.string(itemView.context).also { + binding.subtitleTextView.isVisible = !it.isNullOrBlank() + } + binding.selectButton.isSelected = isSelected + if (option.enabled) { + binding.root.setOnClickListener { toggleSelection(option) } + } } } } -data class RadioOption( - val value: String, - val title: String +data class RadioOption<out T>( + val value: T, + val title: GetString, + val subtitle: GetString? = null, + val enabled: Boolean = true, + val contentDescription: GetString? = null ) + +fun <T> radioOption(value: T, @StringRes title: Int, configure: RadioOptionBuilder<T>.() -> Unit = {}) = + radioOption(value, GetString(title), configure) + +fun <T> radioOption(value: T, title: String, configure: RadioOptionBuilder<T>.() -> Unit = {}) = + radioOption(value, GetString(title), configure) + +fun <T> radioOption(value: T, title: GetString, configure: RadioOptionBuilder<T>.() -> Unit = {}) = + RadioOptionBuilder(value, title).also { it.configure() }.build() + +class RadioOptionBuilder<out T>( + val value: T, + val title: GetString +) { + var subtitle: GetString? = null + var enabled: Boolean = true + var contentDescription: GetString? = null + + fun subtitle(string: String) { + subtitle = GetString(string) + } + + fun subtitle(@StringRes stringRes: Int) { + subtitle = GetString(stringRes) + } + + fun contentDescription(string: String) { + contentDescription = GetString(string) + } + + fun contentDescription(@StringRes stringRes: Int) { + contentDescription = GetString(stringRes) + } + + fun build() = RadioOption( + value, + title, + subtitle, + enabled, + contentDescription + ) +} 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 5f24855760..062a8d44dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -30,9 +30,9 @@ 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.avatars.ProfileContactPhoto 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 import org.session.libsession.utilities.recipients.Recipient @@ -151,6 +151,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } } + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) when (requestCode) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 2c0b39d69d..f6277d1a4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -1,7 +1,9 @@ package org.thoughtcrime.securesms.repository +import network.loki.messenger.libsession_util.util.ExpiryMode import android.content.ContentResolver import android.content.Context +import app.cash.copper.Query import app.cash.copper.flow.observeQuery import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow @@ -23,6 +25,7 @@ import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.DraftDatabase +import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsDatabase @@ -35,6 +38,7 @@ import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.DatabaseComponent import javax.inject.Inject import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -43,6 +47,7 @@ import kotlin.coroutines.suspendCoroutine interface ConversationRepository { fun maybeGetRecipientForThreadId(threadId: Long): Recipient? fun maybeGetBlindedRecipient(recipient: Recipient): Recipient? + fun changes(threadId: Long): Flow<Query> fun recipientUpdateFlow(threadId: Long): Flow<Recipient?> fun saveDraft(threadId: Long, text: String) fun getDraft(threadId: Long): String? @@ -97,6 +102,7 @@ class DefaultConversationRepository @Inject constructor( private val storage: Storage, private val lokiMessageDb: LokiMessageDatabase, private val sessionJobDb: SessionJobDatabase, + private val configDb: ExpirationConfigurationDatabase, private val configFactory: ConfigFactory, private val contentResolver: ContentResolver, ) : ConversationRepository { @@ -114,6 +120,9 @@ class DefaultConversationRepository @Inject constructor( ) } + override fun changes(threadId: Long): Flow<Query> = + contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId)) + override fun recipientUpdateFlow(threadId: Long): Flow<Recipient?> { return contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId)).map { maybeGetRecipientForThreadId(threadId) @@ -141,14 +150,20 @@ class DefaultConversationRepository @Inject constructor( for (contact in contacts) { val message = VisibleMessage() message.sentTimestamp = SnodeAPI.nowWithOffset - val openGroupInvitation = OpenGroupInvitation() - openGroupInvitation.name = openGroup.name - openGroupInvitation.url = openGroup.joinURL + val openGroupInvitation = OpenGroupInvitation().apply { + name = openGroup.name + url = openGroup.joinURL + } message.openGroupInvitation = openGroupInvitation + val expirationConfig = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(contact).let(storage::getExpirationConfiguration) + val expiresInMillis = expirationConfig?.expiryMode?.expiryMillis ?: 0 + val expireStartedAt = if (expirationConfig?.expiryMode is ExpiryMode.AfterSend) message.sentTimestamp!! else 0 val outgoingTextMessage = OutgoingTextMessage.fromOpenGroupInvitation( openGroupInvitation, contact, - message.sentTimestamp + message.sentTimestamp, + expiresInMillis, + expireStartedAt ) smsDb.insertMessageOutbox(-1, outgoingTextMessage, message.sentTimestamp!!, true) MessageSender.send(message, contact.address) @@ -194,7 +209,7 @@ class DefaultConversationRepository @Inject constructor( } } else { messageDataProvider.deleteMessage(message.id, !message.isMms) - messageDataProvider.getServerHashForMessage(message.id)?.let { serverHash -> + messageDataProvider.getServerHashForMessage(message.id, message.isMms)?.let { serverHash -> var publicKey = recipient.address.serialize() if (recipient.isClosedGroupRecipient) { publicKey = GroupUtil.doubleDecodeGroupID(publicKey).toHexString() @@ -211,16 +226,11 @@ class DefaultConversationRepository @Inject constructor( override fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? { if (recipient.isOpenGroupRecipient) return null - messageDataProvider.getServerHashForMessage(message.id) ?: return null - val unsendRequest = UnsendRequest() - if (message.isOutgoing) { - unsendRequest.author = textSecurePreferences.getLocalNumber() - } else { - unsendRequest.author = message.individualRecipient.address.contactIdentifier() - } - unsendRequest.timestamp = message.timestamp - - return unsendRequest + messageDataProvider.getServerHashForMessage(message.id, message.isMms) ?: return null + return UnsendRequest( + author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.contactIdentifier() } ?: textSecurePreferences.getLocalNumber(), + timestamp = message.timestamp + ) } override suspend fun deleteMessageWithoutUnsendRequest( @@ -235,7 +245,7 @@ class DefaultConversationRepository @Inject constructor( lokiMessageDb.getServerID(message.id, !message.isMms) ?: continue messageServerIDs[messageServerID] = message } - for ((messageServerID, message) in messageServerIDs) { + messageServerIDs.forEach { (messageServerID, message) -> OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server) .success { messageDataProvider.deleteMessage(message.id, !message.isMms) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java deleted file mode 100644 index 85d8c8f436..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java +++ /dev/null @@ -1,288 +0,0 @@ -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; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.GroupUtil; -import org.session.libsession.utilities.SSKEnvironment; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.recipients.Recipient; -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; -import org.thoughtcrime.securesms.mms.MmsException; - -import java.io.IOException; -import java.util.Comparator; -import java.util.TreeSet; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; - -public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationManagerProtocol { - - private static final String TAG = ExpiringMessageManager.class.getSimpleName(); - - private final TreeSet<ExpiringMessageReference> expiringMessageReferences = new TreeSet<>(new ExpiringMessageComparator()); - private final Executor executor = Executors.newSingleThreadExecutor(); - - 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()); - } - - public void scheduleDeletion(long id, boolean mms, long expiresInMillis) { - scheduleDeletion(id, mms, System.currentTimeMillis(), expiresInMillis); - } - - public void scheduleDeletion(long id, boolean mms, long startedAtTimestamp, long expiresInMillis) { - long expiresAtMillis = startedAtTimestamp + expiresInMillis; - - synchronized (expiringMessageReferences) { - expiringMessageReferences.add(new ExpiringMessageReference(id, mms, expiresAtMillis)); - expiringMessageReferences.notifyAll(); - } - } - - public void checkSchedule() { - synchronized (expiringMessageReferences) { - expiringMessageReferences.notifyAll(); - } - } - - @Override - public void setExpirationTimer(@NotNull ExpirationTimerUpdate message) { - String userPublicKey = TextSecurePreferences.getLocalNumber(context); - String senderPublicKey = message.getSender(); - - // Notify the user - if (senderPublicKey == null || userPublicKey.equals(senderPublicKey)) { - // sender is self or a linked device - insertOutgoingExpirationTimerMessage(message); - } else { - insertIncomingExpirationTimerMessage(message); - } - - if (message.getId() != null) { - smsDatabase.deleteMessage(message.getId()); - } - } - - private void insertIncomingExpirationTimerMessage(ExpirationTimerUpdate message) { - - String senderPublicKey = message.getSender(); - Long sentTimestamp = message.getSentTimestamp(); - String groupId = message.getGroupPublicKey(); - int duration = message.getDuration(); - - Optional<SignalServiceGroup> groupInfo = Optional.absent(); - Address address = Address.fromSerialized(senderPublicKey); - Recipient recipient = Recipient.from(context, address, false); - - // if the sender is blocked, we don't display the update, except if it's in a closed group - if (recipient.isBlocked() && groupId == null) return; - - try { - if (groupId != null) { - String groupID = GroupUtil.doubleEncodeGroupID(groupId); - groupInfo = Optional.of(new SignalServiceGroup(GroupUtil.getDecodedGroupIDAsData(groupID), SignalServiceGroup.GroupType.SIGNAL)); - - 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, - false, - false, - false, - Optional.absent(), - groupInfo, - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); - //insert the timer update message - mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, true); - - //set the timer to the conversation - MessagingModuleConfiguration.getShared().getStorage().setExpirationTimer(recipient.getAddress().serialize(), duration); - - } catch (IOException | MmsException ioe) { - Log.e("Loki", "Failed to insert expiration update message."); - } - } - - private void insertOutgoingExpirationTimerMessage(ExpirationTimerUpdate message) { - - Long sentTimestamp = message.getSentTimestamp(); - String groupId = message.getGroupPublicKey(); - int duration = message.getDuration(); - - Address address; - - try { - if (groupId != null) { - address = Address.fromSerialized(GroupUtil.doubleEncodeGroupID(groupId)); - } else { - address = Address.fromSerialized((message.getSyncTarget() != null && !message.getSyncTarget().isEmpty()) ? message.getSyncTarget() : message.getRecipient()); - } - - 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.", ioe); - } - } - - @Override - public void disableExpirationTimer(@NotNull ExpirationTimerUpdate message) { - setExpirationTimer(message); - } - - @Override - public void startAnyExpiration(long timestamp, @NotNull String author) { - MessageRecord messageRecord = mmsSmsDatabase.getMessageFor(timestamp, author); - if (messageRecord != null) { - boolean mms = messageRecord.isMms(); - Recipient recipient = messageRecord.getRecipient(); - if (recipient.getExpireMessages() <= 0) return; - if (mms) { - mmsDatabase.markExpireStarted(messageRecord.getId()); - } else { - smsDatabase.markExpireStarted(messageRecord.getId()); - } - scheduleDeletion(messageRecord.getId(), mms, recipient.getExpireMessages() * 1000); - } - } - - private class LoadTask implements Runnable { - - public void run() { - SmsDatabase.Reader smsReader = smsDatabase.readerFor(smsDatabase.getExpirationStartedMessages()); - MmsDatabase.Reader mmsReader = mmsDatabase.getExpireStartedMessages(); - - MessageRecord messageRecord; - - while ((messageRecord = smsReader.getNext()) != null) { - expiringMessageReferences.add(new ExpiringMessageReference(messageRecord.getId(), - messageRecord.isMms(), - messageRecord.getExpireStarted() + messageRecord.getExpiresIn())); - } - - while ((messageRecord = mmsReader.getNext()) != null) { - expiringMessageReferences.add(new ExpiringMessageReference(messageRecord.getId(), - messageRecord.isMms(), - messageRecord.getExpireStarted() + messageRecord.getExpiresIn())); - } - - smsReader.close(); - mmsReader.close(); - } - } - - @SuppressWarnings("InfiniteLoopStatement") - private class ProcessTask implements Runnable { - public void run() { - while (true) { - ExpiringMessageReference expiredMessage = null; - - synchronized (expiringMessageReferences) { - try { - while (expiringMessageReferences.isEmpty()) expiringMessageReferences.wait(); - - ExpiringMessageReference nextReference = expiringMessageReferences.first(); - long waitTime = nextReference.expiresAtMillis - System.currentTimeMillis(); - - if (waitTime > 0) { - ExpirationListener.setAlarm(context, waitTime); - expiringMessageReferences.wait(waitTime); - } else { - expiredMessage = nextReference; - expiringMessageReferences.remove(nextReference); - } - - } catch (InterruptedException e) { - Log.w(TAG, e); - } - } - - if (expiredMessage != null) { - if (expiredMessage.mms) mmsDatabase.deleteMessage(expiredMessage.id); - else smsDatabase.deleteMessage(expiredMessage.id); - } - } - } - } - - private static class ExpiringMessageReference { - private final long id; - private final boolean mms; - private final long expiresAtMillis; - - private ExpiringMessageReference(long id, boolean mms, long expiresAtMillis) { - this.id = id; - this.mms = mms; - this.expiresAtMillis = expiresAtMillis; - } - - @Override - public boolean equals(Object other) { - if (other == null) return false; - if (!(other instanceof ExpiringMessageReference)) return false; - - ExpiringMessageReference that = (ExpiringMessageReference)other; - return this.id == that.id && this.mms == that.mms && this.expiresAtMillis == that.expiresAtMillis; - } - - @Override - public int hashCode() { - return (int)this.id ^ (mms ? 1 : 0) ^ (int)expiresAtMillis; - } - } - - private static class ExpiringMessageComparator implements Comparator<ExpiringMessageReference> { - @Override - public int compare(ExpiringMessageReference lhs, ExpiringMessageReference rhs) { - if (lhs.expiresAtMillis < rhs.expiresAtMillis) return -1; - else if (lhs.expiresAtMillis > rhs.expiresAtMillis) return 1; - else if (lhs.id < rhs.id) return -1; - else if (lhs.id > rhs.id) return 1; - else if (!lhs.mms && rhs.mms) return -1; - else if (lhs.mms && !rhs.mms) return 1; - else return 0; - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt new file mode 100644 index 0000000000..c1dff74333 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt @@ -0,0 +1,225 @@ +package org.thoughtcrime.securesms.service + +import android.content.Context +import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.ExpiryMode.AfterSend +import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.signal.IncomingMediaMessage +import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage +import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID +import org.session.libsession.utilities.GroupUtil.getDecodedGroupIDAsData +import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol +import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.recipients.Recipient +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.dependencies.DatabaseComponent.Companion.get +import org.thoughtcrime.securesms.mms.MmsException +import java.io.IOException +import java.util.TreeSet +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +private val TAG = ExpiringMessageManager::class.java.simpleName +class ExpiringMessageManager(context: Context) : MessageExpirationManagerProtocol { + private val expiringMessageReferences = TreeSet<ExpiringMessageReference>() + private val executor: Executor = Executors.newSingleThreadExecutor() + private val smsDatabase: SmsDatabase + private val mmsDatabase: MmsDatabase + private val mmsSmsDatabase: MmsSmsDatabase + private val context: Context + + init { + this.context = context.applicationContext + smsDatabase = get(context).smsDatabase() + mmsDatabase = get(context).mmsDatabase() + mmsSmsDatabase = get(context).mmsSmsDatabase() + executor.execute(LoadTask()) + executor.execute(ProcessTask()) + } + + private fun getDatabase(mms: Boolean) = if (mms) mmsDatabase else smsDatabase + + fun scheduleDeletion(id: Long, mms: Boolean, startedAtTimestamp: Long, expiresInMillis: Long) { + if (startedAtTimestamp <= 0) return + + val expiresAtMillis = startedAtTimestamp + expiresInMillis + synchronized(expiringMessageReferences) { + expiringMessageReferences += ExpiringMessageReference(id, mms, expiresAtMillis) + (expiringMessageReferences as Object).notifyAll() + } + } + + fun checkSchedule() { + synchronized(expiringMessageReferences) { (expiringMessageReferences as Object).notifyAll() } + } + + private fun insertIncomingExpirationTimerMessage( + message: ExpirationTimerUpdate, + expireStartedAt: Long + ) { + val senderPublicKey = message.sender + val sentTimestamp = message.sentTimestamp + val groupId = message.groupPublicKey + val expiresInMillis = message.expiryMode.expiryMillis + var groupInfo = Optional.absent<SignalServiceGroup?>() + val address = fromSerialized(senderPublicKey!!) + var recipient = Recipient.from(context, address, false) + + // if the sender is blocked, we don't display the update, except if it's in a closed group + if (recipient.isBlocked && groupId == null) return + try { + if (groupId != null) { + val groupID = doubleEncodeGroupID(groupId) + groupInfo = Optional.of( + SignalServiceGroup( + getDecodedGroupIDAsData(groupID), + SignalServiceGroup.GroupType.SIGNAL + ) + ) + val groupAddress = fromSerialized(groupID) + recipient = Recipient.from(context, groupAddress, false) + } + val threadId = shared.storage.getThreadId(recipient) ?: return + val mediaMessage = IncomingMediaMessage( + address, sentTimestamp!!, -1, + expiresInMillis, expireStartedAt, true, + false, + false, + false, + Optional.absent(), + groupInfo, + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent() + ) + //insert the timer update message + mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) + } catch (ioe: IOException) { + Log.e("Loki", "Failed to insert expiration update message.") + } catch (ioe: MmsException) { + Log.e("Loki", "Failed to insert expiration update message.") + } + } + + private fun insertOutgoingExpirationTimerMessage( + message: ExpirationTimerUpdate, + expireStartedAt: Long + ) { + val sentTimestamp = message.sentTimestamp + val groupId = message.groupPublicKey + val duration = message.expiryMode.expiryMillis + try { + val serializedAddress = groupId?.let(::doubleEncodeGroupID) + ?: message.syncTarget?.takeIf { it.isNotEmpty() } + ?: message.recipient!! + val address = fromSerialized(serializedAddress) + val recipient = Recipient.from(context, address, false) + + message.threadID = shared.storage.getOrCreateThreadIdFor(address) + val timerUpdateMessage = OutgoingExpirationUpdateMessage( + recipient, + sentTimestamp!!, + duration, + expireStartedAt, + groupId + ) + mmsDatabase.insertSecureDecryptedMessageOutbox( + timerUpdateMessage, + message.threadID!!, + sentTimestamp, + true + ) + } catch (ioe: MmsException) { + Log.e("Loki", "Failed to insert expiration update message.", ioe) + } catch (ioe: IOException) { + Log.e("Loki", "Failed to insert expiration update message.", ioe) + } + } + + override fun insertExpirationTimerMessage(message: ExpirationTimerUpdate) { + val expiryMode: ExpiryMode = message.expiryMode + + val userPublicKey = getLocalNumber(context) + val senderPublicKey = message.sender + val sentTimestamp = if (message.sentTimestamp == null) 0 else message.sentTimestamp!! + val expireStartedAt = if (expiryMode is AfterSend || message.isSenderSelf) sentTimestamp else 0 + + // Notify the user + if (senderPublicKey == null || userPublicKey == senderPublicKey) { + // sender is self or a linked device + insertOutgoingExpirationTimerMessage(message, expireStartedAt) + } else { + insertIncomingExpirationTimerMessage(message, expireStartedAt) + } + + maybeStartExpiration(message) + } + + override fun startAnyExpiration(timestamp: Long, author: String, expireStartedAt: Long) { + mmsSmsDatabase.getMessageFor(timestamp, author)?.run { + getDatabase(isMms()).markExpireStarted(getId(), expireStartedAt) + scheduleDeletion(getId(), isMms(), expireStartedAt, expiresIn) + } ?: Log.e(TAG, "no message record!") + } + + private inner class LoadTask : Runnable { + override fun run() { + val smsReader = smsDatabase.readerFor(smsDatabase.getExpirationStartedMessages()) + val mmsReader = mmsDatabase.expireStartedMessages + + val smsMessages = smsReader.use { generateSequence { it.next }.toList() } + val mmsMessages = mmsReader.use { generateSequence { it.next }.toList() } + + (smsMessages + mmsMessages).forEach { messageRecord -> + expiringMessageReferences += ExpiringMessageReference( + messageRecord.getId(), + messageRecord.isMms, + messageRecord.expireStarted + messageRecord.expiresIn + ) + } + } + } + + private inner class ProcessTask : Runnable { + override fun run() { + while (true) { + synchronized(expiringMessageReferences) { + try { + while (expiringMessageReferences.isEmpty()) (expiringMessageReferences as Object).wait() + val nextReference = expiringMessageReferences.first() + val waitTime = nextReference.expiresAtMillis - nowWithOffset + if (waitTime > 0) { + ExpirationListener.setAlarm(context, waitTime) + (expiringMessageReferences as Object).wait(waitTime) + null + } else { + expiringMessageReferences -= nextReference + nextReference + } + } catch (e: InterruptedException) { + Log.w(TAG, e) + null + } + }?.run { getDatabase(mms).deleteMessage(id) } + } + } + } + + private data class ExpiringMessageReference( + val id: Long, + val mms: Boolean, + val expiresAtMillis: Long + ): Comparable<ExpiringMessageReference> { + override fun compareTo(other: ExpiringMessageReference) = compareValuesBy(this, other, { it.expiresAtMillis }, { it.id }, { it.mms }) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java index 402c0f6521..f919af7ad6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java @@ -25,8 +25,10 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.content.pm.ServiceInfo; import android.os.AsyncTask; import android.os.Binder; +import android.os.Build; import android.os.IBinder; import android.os.SystemClock; @@ -250,7 +252,11 @@ public class KeyCachingService extends Service { builder.setContentIntent(buildLaunchIntent()); stopForeground(true); - startForeground(SERVICE_RUNNING_ID, builder.build()); + if (Build.VERSION.SDK_INT >= 34) { + startForeground(SERVICE_RUNNING_ID, builder.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE); + } else { + startForeground(SERVICE_RUNNING_ID, builder.build()); + } } private PendingIntent buildLockIntent() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt new file mode 100644 index 0000000000..0b7b6d6b4c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt @@ -0,0 +1,81 @@ +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +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.res.painterResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import network.loki.messenger.R + +@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)) { + com.google.accompanist.pager.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 = null, + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 1724bde8a6..6c223a45f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -1,42 +1,93 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row 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.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ButtonColors +import androidx.compose.material.ButtonDefaults 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.OutlinedButton +import androidx.compose.material.RadioButton 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.draw.alpha +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp 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.session.libsession.utilities.runIf import org.thoughtcrime.securesms.components.ProfilePictureView +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCard +import kotlin.math.min + +interface Callbacks<in T> { + fun onSetClick(): Any? + fun setValue(value: T) +} + +object NoOpCallbacks: Callbacks<Any> { + override fun onSetClick() {} + override fun setValue(value: Any) {} +} + +data class RadioOption<T>( + val value: T, + val title: GetString, + val subtitle: GetString? = null, + val contentDescription: GetString = title, + val selected: Boolean = false, + val enabled: Boolean = true, +) + +@Composable +fun <T> OptionsCard(card: OptionsCard<T>, callbacks: Callbacks<T>) { + Text(text = card.title()) + CellNoMargin { + LazyColumn( + modifier = Modifier.heightIn(max = 5000.dp) + ) { + itemsIndexed(card.options) { i, it -> + if (i != 0) Divider() + TitledRadioButton(it) { callbacks.setValue(it.value) } + } + } + } +} + @Composable fun ItemButton( @@ -95,66 +146,109 @@ fun CellWithPaddingAndMargin( } } +@Composable +fun <T> TitledRadioButton(option: RadioOption<T>, onClick: () -> Unit) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .runIf(option.enabled) { clickable { if (!option.selected) onClick() } } + .heightIn(min = 60.dp) + .padding(horizontal = 32.dp) + .contentDescription(option.contentDescription) + ) { + Column(modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically)) { + Column { + Text( + text = option.title(), + fontSize = 16.sp, + modifier = Modifier.alpha(if (option.enabled) 1f else 0.5f) + ) + option.subtitle?.let { + Text( + text = it(), + fontSize = 11.sp, + modifier = Modifier.alpha(if (option.enabled) 1f else 0.5f) + ) + } + } + } + RadioButton( + selected = option.selected, + onClick = null, + enabled = option.enabled, + modifier = Modifier + .height(26.dp) + .align(Alignment.CenterVertically) + ) + } +} + +@Composable +fun Modifier.contentDescription(text: GetString?): Modifier { + val context = LocalContext.current + return text?.let { semantics { contentDescription = it(context) } } ?: this +} + +@Composable +fun OutlineButton(text: GetString, contentDescription: GetString? = text, modifier: Modifier = Modifier, onClick: () -> Unit) { + OutlinedButton( + modifier = modifier.size(108.dp, 34.dp) + .contentDescription(contentDescription), + onClick = onClick, + border = BorderStroke(1.dp, LocalExtraColors.current.prominentButtonColor), + shape = RoundedCornerShape(50), // = 50% percent + colors = ButtonDefaults.outlinedButtonColors( + contentColor = LocalExtraColors.current.prominentButtonColor, + backgroundColor = MaterialTheme.colors.background + ) + ){ + Text(text = text()) + } +} + 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]) - } - } -} +fun Modifier.fadingEdges( + scrollState: ScrollState, + topEdgeHeight: Dp = 0.dp, + bottomEdgeHeight: Dp = 20.dp +): Modifier = this.then( + Modifier + // adding layer fixes issue with blending gradient and content + .graphicsLayer { alpha = 0.99F } + .drawWithContent { + drawContent() -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun RowScope.CarouselPrevButton(pagerState: PagerState) { - CarouselButton(pagerState, pagerState.canScrollBackward, R.drawable.ic_prev, -1) -} + val topColors = listOf(Color.Transparent, Color.Black) + val topStartY = scrollState.value.toFloat() + val topGradientHeight = min(topEdgeHeight.toPx(), topStartY) + if (topGradientHeight > 0f) drawRect( + brush = Brush.verticalGradient( + colors = topColors, + startY = topStartY, + endY = topStartY + topGradientHeight + ), + blendMode = BlendMode.DstIn + ) -@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 = "", + val bottomColors = listOf(Color.Black, Color.Transparent) + val bottomEndY = size.height - scrollState.maxValue + scrollState.value + val bottomGradientHeight = + min(bottomEdgeHeight.toPx(), scrollState.maxValue.toFloat() - scrollState.value) + if (bottomGradientHeight > 0f) drawRect( + brush = Brush.verticalGradient( + colors = bottomColors, + startY = bottomEndY - bottomGradientHeight, + endY = bottomEndY + ), + blendMode = BlendMode.DstIn ) } - } -} +) @Composable fun Divider() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt index 44ff4a42d8..e472209005 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt @@ -1,28 +1,55 @@ package org.thoughtcrime.securesms.ui +import android.content.Context import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import org.session.libsession.utilities.ExpirationUtil +import kotlin.time.Duration /** * Compatibility class to allow ViewModels to use strings and string resources interchangeably. */ sealed class GetString { + + @Composable + operator fun invoke() = string() + operator fun invoke(context: Context) = string(context) + @Composable abstract fun string(): String + + abstract fun string(context: Context): String data class FromString(val string: String): GetString() { @Composable override fun string(): String = string + override fun string(context: Context): String = string } data class FromResId(@StringRes val resId: Int): GetString() { @Composable override fun string(): String = stringResource(resId) + override fun string(context: Context): String = context.getString(resId) + } + data class FromFun(val function: (Context) -> String): GetString() { + @Composable + override fun string(): String = function(LocalContext.current) + override fun string(context: Context): String = function(context) + } + data class FromMap<T>(val value: T, val function: (Context, T) -> String): GetString() { + @Composable + override fun string(): String = function(LocalContext.current, value) + + override fun string(context: Context): String = function(context, value) } } fun GetString(@StringRes resId: Int) = GetString.FromResId(resId) fun GetString(string: String) = GetString.FromString(string) +fun GetString(function: (Context) -> String) = GetString.FromFun(function) +fun <T> GetString(value: T, function: (Context, T) -> String) = GetString.FromMap(value, function) +fun GetString(duration: Duration) = GetString.FromMap(duration, ExpirationUtil::getExpirationDisplayValue) /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt index 64bbd21d8d..3fa861fb71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt @@ -22,6 +22,7 @@ val LocalExtraColors = staticCompositionLocalOf<ExtraColors> { error("No Custom data class ExtraColors( val settingsBackground: Color, + val prominentButtonColor: Color ) /** @@ -34,6 +35,7 @@ fun AppTheme( val extraColors = LocalContext.current.run { ExtraColors( settingsBackground = getColorFromTheme(R.attr.colorSettingsBackground), + prominentButtonColor = getColorFromTheme(R.attr.prominentButtonColor), ) } 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 06fda29306..4d00da8f96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt @@ -131,6 +131,7 @@ object MockDataGenerator { .joinToString(), Optional.absent(), 0, + 0, false, -1, false @@ -148,6 +149,7 @@ object MockDataGenerator { .map { wordContent.random(dmThreadRandomGenerator.asKotlinRandom()) } .joinToString(), 0, + 0, -1, (timestampNow - (index * 5000)) ), @@ -232,14 +234,12 @@ 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, System.currentTimeMillis()) - storage.setExpirationTimer(groupId, 0) - storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair) + storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair, 0) // Add the group created message if (userSessionId == adminUserId) { storage.insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), threadId, (timestampNow - (numMessages * 5000))) - } - else { + } else { storage.insertIncomingInfoMessage(context, adminUserId, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), (timestampNow - (numMessages * 5000))) } @@ -261,6 +261,7 @@ object MockDataGenerator { .joinToString(), Optional.absent(), 0, + 0, false, -1, false @@ -278,6 +279,7 @@ object MockDataGenerator { .map { wordContent.random(cgThreadRandomGenerator.asKotlinRandom()) } .joinToString(), 0, + 0, -1, (timestampNow - (index * 5000)) ), @@ -386,6 +388,7 @@ object MockDataGenerator { .joinToString(), Optional.absent(), 0, + 0, false, -1, false @@ -402,6 +405,7 @@ object MockDataGenerator { .map { wordContent.random(ogThreadRandomGenerator.asKotlinRandom()) } .joinToString(), 0, + 0, -1, (timestampNow - (index * 5000)) ), 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 8b219849a0..8df2a7cd2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt @@ -66,6 +66,7 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int this.contextReference = WeakReference(context) } + @Deprecated("Deprecated in Java") override fun doInBackground(vararg attachments: Attachment?): Pair<Int, String?> { if (attachments.isEmpty()) { throw IllegalArgumentException("Must pass in at least one attachment") @@ -227,6 +228,7 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int return File(fileName).name } + @Deprecated("Deprecated in Java") override fun onPostExecute(result: Pair<Int, String?>) { super.onPostExecute(result) val context = contextReference.get() ?: return diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt index ea1a90d19a..e5de4c36d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt @@ -29,6 +29,7 @@ class ScanQRCodeWrapperFragment : Fragment(), ScanQRCodePlaceholderFragmentDeleg } } + @Deprecated("Deprecated in Java") override fun setUserVisibleHint(isVisibleToUser: Boolean) { super.setUserVisibleHint(isVisibleToUser) enabled = isVisibleToUser 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 894de9de64..272f2c12db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -16,6 +16,7 @@ import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.bind import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.calls.CallMessageType +import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.snode.SnodeAPI @@ -25,6 +26,7 @@ import org.session.libsession.utilities.Util import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ICE_CANDIDATES import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioDeviceUpdate import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioEnabled import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.RecipientUpdate @@ -57,8 +59,12 @@ import java.util.ArrayDeque import java.util.UUID import org.thoughtcrime.securesms.webrtc.data.State as CallState -class CallManager(context: Context, audioManager: AudioManagerCompat, private val storage: StorageProtocol): PeerConnection.Observer, - SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer { +class CallManager( + private val context: Context, + audioManager: AudioManagerCompat, + private val storage: StorageProtocol +): PeerConnection.Observer, + SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer { sealed class StateEvent { data class AudioEnabled(val isEnabled: Boolean): StateEvent() @@ -293,17 +299,17 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va while (pendingOutgoingIceUpdates.isNotEmpty()) { currentPendings.add(pendingOutgoingIceUpdates.pop()) } - val sdps = currentPendings.map { it.sdp } - val sdpMLineIndexes = currentPendings.map { it.sdpMLineIndex } - val sdpMids = currentPendings.map { it.sdpMid } - MessageSender.sendNonDurably(CallMessage( - ICE_CANDIDATES, - sdps = sdps, - sdpMLineIndexes = sdpMLineIndexes, - sdpMids = sdpMids, - currentCallId - ), currentRecipient.address, isSyncMessage = currentRecipient.isLocalNumber) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(expectedRecipient) + CallMessage( + ICE_CANDIDATES, + sdps = currentPendings.map(IceCandidate::sdp), + sdpMLineIndexes = currentPendings.map(IceCandidate::sdpMLineIndex), + sdpMids = currentPendings.map(IceCandidate::sdpMid), + currentCallId + ) + .applyExpiryMode(thread) + .also { MessageSender.sendNonDurably(it, currentRecipient.address, isSyncMessage = currentRecipient.isLocalNumber) } } } } @@ -419,6 +425,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va fun onNewOffer(offer: String, callId: UUID, recipient: Recipient): Promise<Unit, Exception> { if (callId != this.callId) return Promise.ofFail(NullPointerException("No callId")) if (recipient != this.recipient) return Promise.ofFail(NullPointerException("No recipient")) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) val connection = peerConnection ?: return Promise.ofFail(NullPointerException("No peer connection wrapper")) @@ -431,11 +438,9 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true")) }) connection.setLocalDescription(answer) - pendingIncomingIceUpdates.toList().forEach { update -> - connection.addIceCandidate(update) - } + pendingIncomingIceUpdates.toList().forEach(connection::addIceCandidate) pendingIncomingIceUpdates.clear() - val answerMessage = CallMessage.answer(answer.description, callId) + val answerMessage = CallMessage.answer(answer.description, callId).applyExpiryMode(thread) Log.i("Loki", "Posting new answer") MessageSender.sendNonDurably(answerMessage, recipient.address, isSyncMessage = recipient.isLocalNumber) } else { @@ -479,13 +484,14 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va connection.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, offer)) val answer = connection.createAnswer(MediaConstraints()) connection.setLocalDescription(answer) - val answerMessage = CallMessage.answer(answer.description, callId) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + val answerMessage = CallMessage.answer(answer.description, callId).applyExpiryMode(thread) val userAddress = storage.getUserPublicKey() ?: return Promise.ofFail(NullPointerException("No user public key")) MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress), isSyncMessage = true) val sendAnswerMessage = MessageSender.sendNonDurably(CallMessage.answer( answer.description, callId - ), recipient.address, isSyncMessage = recipient.isLocalNumber) + ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_INCOMING, false) @@ -533,15 +539,16 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va connection.setLocalDescription(offer) Log.d("Loki", "Sending pre-offer") + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) return MessageSender.sendNonDurably(CallMessage.preOffer( callId - ), recipient.address, isSyncMessage = recipient.isLocalNumber).bind { + ).applyExpiryMode(thread), 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, isSyncMessage = recipient.isLocalNumber).success { + ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber).success { Log.d("Loki", "Sent offer") }.fail { Log.e("Loki", "Failed to send offer", it) @@ -555,8 +562,9 @@ 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), isSyncMessage = true) - MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address, isSyncMessage = recipient.isLocalNumber) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + MessageSender.sendNonDurably(CallMessage.endCall(callId).applyExpiryMode(thread), Address.fromSerialized(userAddress), isSyncMessage = true) + MessageSender.sendNonDurably(CallMessage.endCall(callId).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_MISSED) } } @@ -575,7 +583,9 @@ 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, isSyncMessage = recipient.isLocalNumber) + + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + MessageSender.sendNonDurably(CallMessage.endCall(callId).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) } } @@ -725,8 +735,8 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true")) }) connection.setLocalDescription(offer) - - MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address, isSyncMessage = recipient.isLocalNumber) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt index 26d8fc223d..4835ab0dc1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt @@ -50,7 +50,7 @@ class PeerConnectionWrapper(private val context: Context, private fun initPeerConnection() { val random = SecureRandom().asKotlinRandom() - val iceServers = listOf("freyr","fenrir","frigg","angus","hereford","holstein", "brahman").shuffled(random).take(2).map { sub -> + val iceServers = listOf("freyr","angus","hereford","holstein", "brahman").shuffled(random).take(2).map { sub -> PeerConnection.IceServer.builder("turn:$sub.getsession.org") .setUsername("session202111") .setPassword("053c268164bc7bd7") diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt index 09db0022d8..dbbbffc3e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt @@ -19,6 +19,7 @@ class HangUpRtcOnPstnCallAnsweredListener(private val hangupListener: ()->Unit): private val TAG = Log.tag(HangUpRtcOnPstnCallAnsweredListener::class.java) } + @Deprecated("Deprecated in Java") override fun onCallStateChanged(state: Int, phoneNumber: String?) { super.onCallStateChanged(state, phoneNumber) if (state == TelephonyManager.CALL_STATE_OFFHOOK) { diff --git a/app/src/main/res/drawable/call_message_background.xml b/app/src/main/res/drawable/call_message_background.xml new file mode 100644 index 0000000000..7713909167 --- /dev/null +++ b/app/src/main/res/drawable/call_message_background.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape + xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + + <solid android:color="?message_received_background_color" /> + + <corners android:radius="@dimen/message_corner_radius" /> +</shape> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24dp.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24dp.xml new file mode 100644 index 0000000000..f3dfdd0bdb --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24dp.xml @@ -0,0 +1,5 @@ +<vector android:height="24dp" android:tint="#000000" + android:viewportHeight="24" android:viewportWidth="24" + android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="@android:color/white" android:pathData="M15.41,16.59L10.83,12l4.58,-4.59L14,6l-6,6 6,6 1.41,-1.41z"/> +</vector> diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24dp.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24dp.xml new file mode 100644 index 0000000000..19ec5a5eb8 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24dp.xml @@ -0,0 +1,5 @@ +<vector android:height="24dp" android:tint="#000000" + android:viewportHeight="24" android:viewportWidth="24" + android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="@android:color/white" android:pathData="M8.59,16.59L13.17,12 8.59,7.41 10,6l6,6 -6,6 -1.41,-1.41z"/> +</vector> diff --git a/app/src/main/res/drawable/ic_baseline_timer_off_24.xml b/app/src/main/res/drawable/ic_baseline_timer_off_24.xml deleted file mode 100644 index 9216da7aff..0000000000 --- a/app/src/main/res/drawable/ic_baseline_timer_off_24.xml +++ /dev/null @@ -1,10 +0,0 @@ -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - <path - android:fillColor="@android:color/white" - android:pathData="M19.04,4.55l-1.42,1.42C16.07,4.74 14.12,4 12,4c-1.83,0 -3.53,0.55 -4.95,1.48l1.46,1.46C9.53,6.35 10.73,6 12,6c3.87,0 7,3.13 7,7 0,1.27 -0.35,2.47 -0.94,3.49l1.45,1.45C20.45,16.53 21,14.83 21,13c0,-2.12 -0.74,-4.07 -1.97,-5.61l1.42,-1.42 -1.41,-1.42zM15,1L9,1v2h6L15,1zM11,9.44l2,2L13,8h-2v1.44zM3.02,4L1.75,5.27 4.5,8.03C3.55,9.45 3,11.16 3,13c0,4.97 4.02,9 9,9 1.84,0 3.55,-0.55 4.98,-1.5l2.5,2.5 1.27,-1.27 -7.71,-7.71L3.02,4zM12,20c-3.87,0 -7,-3.13 -7,-7 0,-1.28 0.35,-2.48 0.95,-3.52l9.56,9.56c-1.03,0.61 -2.23,0.96 -3.51,0.96z"/> -</vector> diff --git a/app/src/main/res/drawable/ic_incoming_call.xml b/app/src/main/res/drawable/ic_incoming_call.xml index da1a78fe50..f9149818c5 100644 --- a/app/src/main/res/drawable/ic_incoming_call.xml +++ b/app/src/main/res/drawable/ic_incoming_call.xml @@ -1,6 +1,6 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="20dp" - android:height="20dp" + android:width="16dp" + android:height="16dp" android:viewportWidth="20" android:viewportHeight="20"> <path diff --git a/app/src/main/res/drawable/ic_missed_call.xml b/app/src/main/res/drawable/ic_missed_call.xml index 4d3aee2a09..e63537737b 100644 --- a/app/src/main/res/drawable/ic_missed_call.xml +++ b/app/src/main/res/drawable/ic_missed_call.xml @@ -1,6 +1,6 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="20dp" - android:height="20dp" + android:width="16dp" + android:height="16dp" android:viewportWidth="20" android:viewportHeight="20"> <path diff --git a/app/src/main/res/drawable/ic_outgoing_call.xml b/app/src/main/res/drawable/ic_outgoing_call.xml index ad27f18d52..26024999ee 100644 --- a/app/src/main/res/drawable/ic_outgoing_call.xml +++ b/app/src/main/res/drawable/ic_outgoing_call.xml @@ -1,6 +1,6 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="20dp" - android:height="20dp" + android:width="16dp" + android:height="16dp" android:viewportWidth="20" android:viewportHeight="20"> <path diff --git a/app/src/main/res/drawable/ic_outline_settings_24.xml b/app/src/main/res/drawable/ic_outline_settings_24.xml new file mode 100644 index 0000000000..c939e9ce8b --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_settings_24.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98 0,-0.34 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.09,-0.16 -0.26,-0.25 -0.44,-0.25 -0.06,0 -0.12,0.01 -0.17,0.03l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.06,-0.02 -0.12,-0.03 -0.18,-0.03 -0.17,0 -0.34,0.09 -0.43,0.25l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98 0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.09,0.16 0.26,0.25 0.44,0.25 0.06,0 0.12,-0.01 0.17,-0.03l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.06,0.02 0.12,0.03 0.18,0.03 0.17,0 0.34,-0.09 0.43,-0.25l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64l-2.11,-1.65zM17.45,11.27c0.04,0.31 0.05,0.52 0.05,0.73 0,0.21 -0.02,0.43 -0.05,0.73l-0.14,1.13 0.89,0.7 1.08,0.84 -0.7,1.21 -1.27,-0.51 -1.04,-0.42 -0.9,0.68c-0.43,0.32 -0.84,0.56 -1.25,0.73l-1.06,0.43 -0.16,1.13 -0.2,1.35h-1.4l-0.19,-1.35 -0.16,-1.13 -1.06,-0.43c-0.43,-0.18 -0.83,-0.41 -1.23,-0.71l-0.91,-0.7 -1.06,0.43 -1.27,0.51 -0.7,-1.21 1.08,-0.84 0.89,-0.7 -0.14,-1.13c-0.03,-0.31 -0.05,-0.54 -0.05,-0.74s0.02,-0.43 0.05,-0.73l0.14,-1.13 -0.89,-0.7 -1.08,-0.84 0.7,-1.21 1.27,0.51 1.04,0.42 0.9,-0.68c0.43,-0.32 0.84,-0.56 1.25,-0.73l1.06,-0.43 0.16,-1.13 0.2,-1.35h1.39l0.19,1.35 0.16,1.13 1.06,0.43c0.43,0.18 0.83,0.41 1.23,0.71l0.91,0.7 1.06,-0.43 1.27,-0.51 0.7,1.21 -1.07,0.85 -0.89,0.7 0.14,1.13zM12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" /> +</vector> diff --git a/app/src/main/res/drawable/tab_indicator_dot.xml b/app/src/main/res/drawable/tab_indicator_dot.xml new file mode 100644 index 0000000000..72f57dc8bc --- /dev/null +++ b/app/src/main/res/drawable/tab_indicator_dot.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_selected="true"> + <shape + android:shape="ring" + android:innerRadius="0dp" + android:thickness="2dp" + android:useLevel="false"> + <solid android:color="?android:textColorPrimary"/> + </shape> + </item> + <item> + <shape + android:shape="ring" + android:innerRadius="0dp" + android:thickness="2dp" + android:useLevel="false"> + <solid android:color="?android:textColorTertiary"/> + </shape> + </item> +</selector> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 5afde1e296..000b860841 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -16,8 +16,10 @@ android:background="?colorPrimary" app:contentInsetStart="0dp"> - <include android:id="@+id/toolbarContent" - layout="@layout/activity_conversation_v2_action_bar" /> + <org.thoughtcrime.securesms.conversation.ConversationActionBarView + android:id="@+id/toolbarContent" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> </androidx.appcompat.widget.Toolbar> @@ -216,6 +218,29 @@ </RelativeLayout> + <RelativeLayout + android:id="@+id/outdatedBanner" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_below="@+id/blockedBanner" + android:background="@color/outdated_client_banner_background_color" + android:visibility="gone" + tools:visibility="visible"> + + <TextView + android:id="@+id/outdatedBannerTextView" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:layout_centerInParent="true" + android:layout_marginVertical="@dimen/very_small_spacing" + android:layout_marginHorizontal="@dimen/medium_spacing" + android:textColor="@color/black" + android:textSize="@dimen/tiny_font_size" + tools:text="This user's client is outdated, things may not work as expected" /> + + </RelativeLayout> + <TextView android:padding="@dimen/medium_spacing" android:textSize="@dimen/small_font_size" diff --git a/app/src/main/res/layout/activity_conversation_v2_action_bar.xml b/app/src/main/res/layout/activity_conversation_v2_action_bar.xml deleted file mode 100644 index 7322bb7f00..0000000000 --- a/app/src/main/res/layout/activity_conversation_v2_action_bar.xml +++ /dev/null @@ -1,68 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<LinearLayout - xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="?attr/actionBarSize" - xmlns:tools="http://schemas.android.com/tools" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:orientation="horizontal" - android:gravity="center_vertical"> - - <org.thoughtcrime.securesms.components.ProfilePictureView - android:id="@+id/profilePictureView" - android:layout_width="@dimen/medium_profile_picture_size" - android:layout_height="@dimen/medium_profile_picture_size" /> - - <LinearLayout - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:orientation="vertical"> - - <TextView - android:id="@+id/conversationTitleView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="12dp" - android:contentDescription="@string/AccessibilityId_username" - tools:text="@tools:sample/full_names" - android:textColor="?android:textColorPrimary" - android:textStyle="bold" - android:textSize="@dimen/very_large_font_size" - android:maxLines="1" - android:ellipsize="end" /> - - <LinearLayout - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="12dp" - android:orientation="horizontal" - android:gravity="center_vertical"> - - <ImageView - android:id="@+id/muteIconImageView" - android:layout_width="14dp" - android:layout_height="14dp" - android:layout_marginEnd="4dp" - android:layout_gravity="center" - android:src="@drawable/ic_outline_notifications_off_24" - app:tint="?android:textColorPrimary" - android:alpha="0.6" - android:visibility="gone" - tools:visibility="visible"/> - - <TextView - android:id="@+id/conversationSubtitleView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="Muted" - android:textColor="?android:textColorPrimary" - android:alpha="0.6" - android:textSize="@dimen/very_small_font_size" - android:maxLines="1" - android:ellipsize="end" /> - - </LinearLayout> - - </LinearLayout> - -</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_disappearing_messages.xml b/app/src/main/res/layout/activity_disappearing_messages.xml new file mode 100644 index 0000000000..8101297732 --- /dev/null +++ b/app/src/main/res/layout/activity_disappearing_messages.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <androidx.appcompat.widget.Toolbar + android:id="@+id/toolbar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?colorPrimary" + app:contentInsetStart="0dp" + app:subtitle="@string/activity_disappearing_messages_subtitle" + app:subtitleTextAppearance="@style/TextAppearance.Session.ToolbarSubtitle" + app:title="@string/activity_disappearing_messages_title" /> + + <androidx.compose.ui.platform.ComposeView + android:id="@+id/container" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + +</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index bf308612c5..124a44b374 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -110,7 +110,7 @@ android:layout_gravity="center" android:textColor="?message_sent_text_color" android:background="?colorAccent" - android:textSize="9sp" + android:textSize="11sp" android:paddingVertical="4dp" android:paddingHorizontal="64dp" android:gravity="center" diff --git a/app/src/main/res/layout/context_menu_item.xml b/app/src/main/res/layout/context_menu_item.xml index 04354f5b10..83d43d82d7 100644 --- a/app/src/main/res/layout/context_menu_item.xml +++ b/app/src/main/res/layout/context_menu_item.xml @@ -18,13 +18,28 @@ android:layout_height="24dp" tools:src="@drawable/ic_message"/> - <TextView - android:id="@+id/context_menu_item_title" - android:textAppearance="@style/TextAppearance.AppCompat.Body1" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_weight="1" + <LinearLayout + android:layout_width="match_parent" android:layout_marginStart="16dp" - tools:text="Archive" /> + android:layout_height="wrap_content" + android:orientation="vertical"> + + <TextView + android:id="@+id/context_menu_item_title" + android:textAppearance="@style/TextAppearance.AppCompat.Body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + tools:text="Archive" /> + + <TextView + android:id="@+id/context_menu_item_subtitle" + android:textSize="@dimen/tiny_font_size" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + tools:text="subtitle" /> + + </LinearLayout> </LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_item_footer.xml b/app/src/main/res/layout/conversation_item_footer.xml deleted file mode 100644 index 3aefb39444..0000000000 --- a/app/src/main/res/layout/conversation_item_footer.xml +++ /dev/null @@ -1,57 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<merge xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:orientation="horizontal" - tools:parentTag="org.thoughtcrime.securesms.components.ConversationItemFooter"> - - <LinearLayout - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_weight="1" - android:layout_marginEnd="6dp" - android:orientation="horizontal" - android:gravity="left|start|center_vertical"> - - <TextView - android:id="@+id/footer_date" - android:autoLink="none" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:textSize="@dimen/very_small_font_size" - android:linksClickable="false" - style="@style/Signal.Text.Caption.MessageSent" - android:textColor="?conversation_item_sent_text_secondary_color" - android:textAllCaps="true" - tools:text="30 mins"/> - - <org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView - android:id="@+id/footer_expiration_timer" - android:layout_gravity="center_vertical|end" - android:layout_marginStart="6dp" - android:layout_width="12dp" - android:layout_height="12dp" - android:contentDescription="@string/AccessibilityId_timer_icon" - android:visibility="gone" - tools:visibility="visible"/> - - </LinearLayout> - - <ImageView - android:id="@+id/footer_insecure_indicator" - android:layout_width="12dp" - android:layout_height="11dp" - android:src="@drawable/ic_unlocked_white_18dp" - android:visibility="gone" - android:layout_gravity="center_vertical|end" - android:contentDescription="@string/conversation_item__secure_message_description" - tools:visibility="visible"/> - - <org.thoughtcrime.securesms.components.DeliveryStatusView - android:id="@+id/footer_delivery_status" - android:layout_width="20dp" - android:layout_height="wrap_content" - android:layout_gravity="center_vertical" /> - -</merge> diff --git a/app/src/main/res/layout/expiration_dialog.xml b/app/src/main/res/layout/expiration_dialog.xml deleted file mode 100644 index 75f5988cab..0000000000 --- a/app/src/main/res/layout/expiration_dialog.xml +++ /dev/null @@ -1,37 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<RelativeLayout - xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:background="?colorPrimary" - android:orientation="vertical" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_gravity="center" - android:gravity="center"> - - <cn.carbswang.android.numberpickerview.library.NumberPickerView - android:id="@+id/expiration_number_picker" - android:contentDescription="@string/AccessibilityId_disappearing_messages_time_picker" - android:layout_alignParentTop="true" - app:npv_WrapSelectorWheel="false" - app:npv_DividerColor="#cbc8ea" - app:npv_TextColorNormal="?android:textColorPrimary" - app:npv_TextColorSelected="?android:textColorPrimary" - app:npv_ItemPaddingVertical="20dp" - app:npv_TextColorHint="@color/grey_600" - app:npv_TextSizeNormal="16sp" - app:npv_TextSizeSelected="16sp" - android:layout_width="match_parent" - android:layout_height="wrap_content"/> - - <TextView - android:id="@+id/expiration_details" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_below="@id/expiration_number_picker" - android:minLines="3" - android:padding="20dp" - tools:text="Your messages will not expire."/> - -</RelativeLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/expiration_timer_menu.xml b/app/src/main/res/layout/expiration_timer_menu.xml deleted file mode 100644 index 5139045021..0000000000 --- a/app/src/main/res/layout/expiration_timer_menu.xml +++ /dev/null @@ -1,32 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<FrameLayout - xmlns:android="http://schemas.android.com/apk/res/android" - style="?android:attr/actionButtonStyle" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:clickable="true" - android:focusable="true" - android:gravity="center"> - - <ImageView - android:id="@+id/menu_badge_icon" - android:layout_width="16dp" - android:layout_height="16dp" - android:layout_gravity="center" - android:src="@drawable/ic_timer" - android:background="@color/transparent" - android:scaleType="fitCenter"/> - - <TextView - android:id="@+id/expiration_badge" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_gravity="bottom|center_horizontal" - android:gravity="center_horizontal|bottom" - android:paddingBottom="3dp" - android:paddingTop="1dp" - android:background="@color/transparent" - android:textColor="?android:textColorPrimary" - android:textSize="10sp" /> - -</FrameLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_enter_community_url.xml b/app/src/main/res/layout/fragment_enter_community_url.xml index 25da784762..1fcfecd654 100644 --- a/app/src/main/res/layout/fragment_enter_community_url.xml +++ b/app/src/main/res/layout/fragment_enter_community_url.xml @@ -18,6 +18,7 @@ <EditText android:id="@+id/communityUrlEditText" + android:contentDescription="@string/AccessibilityId_community_input_box" style="@style/SmallSessionEditText" android:layout_width="match_parent" android:layout_height="64dp" @@ -91,6 +92,7 @@ <Button android:id="@+id/joinCommunityButton" style="@style/Widget.Session.Button.Common.ProminentOutline" + android:contentDescription="@string/AccessibilityId_join_community_button" android:layout_width="196dp" android:layout_height="@dimen/medium_button_height" android:layout_marginVertical="@dimen/large_spacing" diff --git a/app/src/main/res/layout/item_selectable.xml b/app/src/main/res/layout/item_selectable.xml index 886bde7332..0dcb4d526a 100644 --- a/app/src/main/res/layout/item_selectable.xml +++ b/app/src/main/res/layout/item_selectable.xml @@ -1,12 +1,10 @@ <?xml version="1.0" encoding="utf-8"?> -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" - android:id="@+id/backgroundContainer" + xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?selectableItemBackground" - android:gravity="center_vertical" - android:orientation="horizontal" android:paddingHorizontal="@dimen/medium_spacing" android:paddingVertical="@dimen/small_spacing"> @@ -14,18 +12,44 @@ android:id="@+id/titleTextView" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_margin="@dimen/medium_spacing" - android:layout_weight="1" + android:layout_marginTop="@dimen/small_spacing" android:ellipsize="end" android:lines="1" android:textSize="@dimen/text_size" - tools:text="@tools:sample/full_names" /> + android:textStyle="bold" + app:layout_goneMarginBottom="@dimen/small_spacing" + app:layout_constraintEnd_toStartOf="@id/selectButton" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/subtitleTextView" + tools:text="@tools:sample/cities" /> + + <TextView + android:id="@+id/subtitleTextView" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/small_spacing" + android:ellipsize="end" + android:lines="1" + android:textSize="@dimen/very_small_font_size" + android:visibility="gone" + app:layout_constraintEnd_toStartOf="@id/selectButton" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/titleTextView" + tools:text="@tools:sample/full_names" + tools:visibility="visible"/> <View android:id="@+id/selectButton" android:layout_width="@dimen/small_radial_size" android:layout_height="@dimen/small_radial_size" android:background="@drawable/padded_circle_accent_select" - android:foreground="@drawable/radial_multi_select" /> + android:foreground="@drawable/radial_multi_select" + app:layout_constraintHorizontal_bias="1" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toEndOf="@id/titleTextView" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintEnd_toEndOf="parent"/> -</LinearLayout> \ No newline at end of file +</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/longmessage_activity.xml b/app/src/main/res/layout/longmessage_activity.xml deleted file mode 100644 index 0436fdae2f..0000000000 --- a/app/src/main/res/layout/longmessage_activity.xml +++ /dev/null @@ -1,23 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<ScrollView - xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:padding="16dp"> - - <TextView - android:textSize="@dimen/text_size" - android:focusable="true" - android:textColorLink="?android:textColorPrimary" - android:id="@+id/longmessage_text" - android:layout_width="match_parent" - android:layout_height="wrap_content"/> - - </LinearLayout> - -</ScrollView> \ No newline at end of file diff --git a/app/src/main/res/layout/view_control_message.xml b/app/src/main/res/layout/view_control_message.xml index 03923677db..3cbe26048e 100644 --- a/app/src/main/res/layout/view_control_message.xml +++ b/app/src/main/res/layout/view_control_message.xml @@ -29,6 +29,16 @@ tools:src="@drawable/ic_timer" tools:visibility="visible"/> + <org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView + android:id="@+id/expirationTimerView" + android:layout_width="12dp" + android:layout_height="12dp" + android:layout_marginBottom="@dimen/small_spacing" + android:visibility="gone" + app:tint="?android:textColorPrimary" + tools:src="@drawable/ic_timer" + tools:visibility="visible"/> + <TextView android:id="@+id/textView" android:contentDescription="@string/AccessibilityId_control_message" @@ -40,4 +50,35 @@ android:textStyle="bold" tools:text="@string/MessageRecord_you_disabled_disappearing_messages" /> + <FrameLayout + android:id="@+id/call_view" + style="@style/CallMessage" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal"> + + <TextView + android:id="@+id/call_text_view" + android:textColor="?message_received_text_color" + android:textAlignment="center" + android:layout_gravity="center" + tools:text="You missed a call" + android:layout_width="match_parent" + android:layout_height="match_parent" + app:drawableTint="?message_received_text_color" + app:drawableStartCompat="@drawable/ic_missed_call" /> + + </FrameLayout> + + <TextView + android:id="@+id/followSetting" + style="@style/Widget.Session.Button.Common.Borderless" + android:layout_marginTop="4dp" + android:textColor="@color/accent_green" + android:textSize="@dimen/very_small_font_size" + android:text="@string/MessageRecord_follow_setting" + android:contentDescription="@string/AccessibilityId_follow_setting" + android:layout_width="match_parent" + android:layout_height="wrap_content"/> + </LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/view_conversation_action_bar.xml b/app/src/main/res/layout/view_conversation_action_bar.xml new file mode 100644 index 0000000000..db51410b86 --- /dev/null +++ b/app/src/main/res/layout/view_conversation_action_bar.xml @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="?attr/actionBarSize" + android:orientation="horizontal" + android:gravity="center_vertical"> + + <org.thoughtcrime.securesms.components.ProfilePictureView + android:id="@+id/profilePictureView" + android:layout_width="@dimen/medium_profile_picture_size" + android:layout_height="@dimen/medium_profile_picture_size" + android:layout_marginTop="@dimen/medium_spacing" + android:layout_marginStart="@dimen/medium_spacing" + android:layout_marginBottom="@dimen/medium_spacing" /> + + <LinearLayout + android:id="@+id/conversationTitleContainer" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="center_horizontal|center_vertical" + android:orientation="vertical"> + + <TextView + android:id="@+id/conversationTitleView" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:contentDescription="@string/AccessibilityId_conversation_header_name" + tools:text="@tools:sample/full_names" + android:textColor="?android:textColorPrimary" + android:textStyle="bold" + android:textSize="@dimen/large_font_size" + android:maxLines="1" + android:ellipsize="end" /> + + <androidx.viewpager2.widget.ViewPager2 + android:id="@+id/settings_pager" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/very_small_spacing"/> + + <com.google.android.material.tabs.TabLayout + android:id="@+id/settings_tab_layout" + android:layout_width="wrap_content" + android:layout_height="@dimen/very_small_spacing" + app:tabBackground="@drawable/tab_indicator_dot" + app:tabGravity="center" + app:tabIndicator="@null" + app:tabPaddingStart="@dimen/very_small_spacing" + app:tabPaddingEnd="@dimen/very_small_spacing"/> + + </LinearLayout> + +</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/view_conversation_setting.xml b/app/src/main/res/layout/view_conversation_setting.xml new file mode 100644 index 0000000000..688aa01f37 --- /dev/null +++ b/app/src/main/res/layout/view_conversation_setting.xml @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="center_horizontal"> + + <ImageView + android:id="@+id/leftArrowImageView" + android:layout_width="14dp" + android:layout_height="14dp" + android:layout_gravity="center" + android:alpha="0.6" + android:visibility="gone" + android:src="@drawable/ic_baseline_keyboard_arrow_left_24dp" + app:tint="?android:textColorPrimary" + tools:visibility="visible" /> + + <ImageView + android:id="@+id/iconImageView" + android:layout_width="14dp" + android:layout_height="14dp" + android:layout_gravity="center" + android:layout_marginEnd="4dp" + android:alpha="0.6" + android:visibility="gone" + app:tint="?android:textColorPrimary" + tools:src="@drawable/ic_outline_notifications_off_24" + tools:visibility="visible" /> + + <TextView + android:id="@+id/titleView" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:alpha="0.6" + android:ellipsize="end" + android:maxLines="1" + android:layout_gravity="center" + android:textColor="?android:textColorPrimary" + android:textSize="@dimen/very_small_font_size" + tools:text="Muted" /> + + <ImageView + android:id="@+id/rightArrowImageView" + android:layout_width="14dp" + android:layout_height="14dp" + android:layout_gravity="center" + android:alpha="0.6" + android:visibility="gone" + android:src="@drawable/ic_baseline_keyboard_arrow_right_24dp" + app:tint="?android:textColorPrimary" + tools:visibility="visible" /> + +</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/view_document.xml b/app/src/main/res/layout/view_document.xml index 713b079601..c760a1a592 100644 --- a/app/src/main/res/layout/view_document.xml +++ b/app/src/main/res/layout/view_document.xml @@ -10,10 +10,17 @@ android:gravity="center" android:contentDescription="@string/AccessibilityId_document"> + <com.google.android.material.progressindicator.CircularProgressIndicator + android:id="@+id/documentViewProgress" + style="@style/Widget.Material3.CircularProgressIndicator.Small" + android:indeterminate="true" + android:layout_width="36dp" + android:layout_height="36dp"/> + <ImageView android:id="@+id/documentViewIconImageView" - android:layout_width="24dp" - android:layout_height="24dp" + android:layout_width="36dp" + android:layout_height="36dp" android:src="@drawable/ic_document_large_light" app:tint="?android:textColorPrimary" /> diff --git a/app/src/main/res/layout/view_input_bar.xml b/app/src/main/res/layout/view_input_bar.xml index 178f9e1e6d..22240867c1 100644 --- a/app/src/main/res/layout/view_input_bar.xml +++ b/app/src/main/res/layout/view_input_bar.xml @@ -12,8 +12,11 @@ android:layout_height="1px" android:background="@color/separator" /> - <FrameLayout + <!-- Additional content layout is a LinearLayout with a vertical split (i.e., it uses rows) to + allow multiple Views to exist, specifically both QuoteDraft and LinkPreviewDraft Views --> + <LinearLayout android:id="@+id/inputBarAdditionalContentContainer" + android:orientation="vertical" android:layout_width="match_parent" android:layout_height="wrap_content" /> diff --git a/app/src/main/res/layout/view_profile_overflow.xml b/app/src/main/res/layout/view_profile_overflow.xml new file mode 100644 index 0000000000..4214c76548 --- /dev/null +++ b/app/src/main/res/layout/view_profile_overflow.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="?android:attr/actionBarSize" + android:layout_height="?android:attr/actionBarSize"> + + <LinearLayout + android:layout_width="@dimen/small_profile_picture_size" + android:layout_height="@dimen/small_profile_picture_size" + android:layout_gravity="center" + android:background="?attr/selectableItemBackgroundBorderless"> + + <include + android:id="@+id/profilePictureView" + layout="@layout/view_profile_picture" /> + + </LinearLayout> + +</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/view_visible_message.xml b/app/src/main/res/layout/view_visible_message.xml index c9badb415d..19f9b4f9ad 100644 --- a/app/src/main/res/layout/view_visible_message.xml +++ b/app/src/main/res/layout/view_visible_message.xml @@ -126,18 +126,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" /> - <org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView - android:id="@+id/expirationTimerView" - android:layout_width="12dp" - android:layout_height="12dp" - android:layout_gravity="center_vertical" - android:layout_marginHorizontal="@dimen/small_spacing" - android:contentDescription="@string/AccessibilityId_timer_icon" - android:visibility="invisible" - tools:visibility="visible" - tools:src="@drawable/timer60" - tools:tint="@color/black"/> - </LinearLayout> </FrameLayout> @@ -150,28 +138,41 @@ app:layout_constraintStart_toStartOf="@+id/messageInnerContainer" app:layout_constraintTop_toBottomOf="@id/messageInnerContainer" /> - <TextView - android:id="@+id/messageStatusTextView" + <LinearLayout + android:id="@+id/statusContainer" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginEnd="2dp" - android:layout_marginBottom="1dp" - app:layout_constraintTop_toTopOf="@id/messageStatusImageView" - app:layout_constraintBottom_toBottomOf="@id/messageStatusImageView" - app:layout_constraintEnd_toStartOf="@id/messageStatusImageView" - android:textSize="@dimen/very_small_font_size" - android:textColor="@color/classic_dark_1" - tools:text="Sent" /> - - <ImageView - android:id="@+id/messageStatusImageView" - android:layout_width="12dp" - android:layout_height="12dp" - android:layout_marginTop="5dp" - app:layout_constraintEnd_toEndOf="parent" + android:baselineAligned="true" app:layout_constraintTop_toBottomOf="@+id/emojiReactionsView" - tools:tint="@color/classic_dark_1" - android:src="@drawable/ic_delivery_status_sent" /> + app:layout_constraintStart_toStartOf="@id/messageInnerContainer" + app:layout_constraintHorizontal_bias="1" + app:layout_constraintEnd_toEndOf="parent"> + + <TextView + android:id="@+id/messageStatusTextView" + android:contentDescription="@string/AccessibilityId_message_sent_status" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="2dp" + android:layout_gravity="center" + android:textSize="@dimen/very_small_font_size" + tools:text="Sent" /> + + <ImageView + android:id="@+id/messageStatusImageView" + android:layout_width="12dp" + android:layout_height="12dp" + android:layout_gravity="center" + android:src="@drawable/ic_delivery_status_sent" /> + + <org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView + android:id="@+id/expirationTimerView" + android:layout_width="12dp" + android:layout_height="12dp" + android:layout_gravity="center" + android:tint="?message_status_color" /> + + </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/view_visible_message_content.xml b/app/src/main/res/layout/view_visible_message_content.xml index e897bcea58..2c56229a8a 100644 --- a/app/src/main/res/layout/view_visible_message_content.xml +++ b/app/src/main/res/layout/view_visible_message_content.xml @@ -36,15 +36,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content"/> - <include layout="@layout/view_voice_message" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toStartOf="parent" - android:visibility="gone" - android:id="@+id/voiceMessageView" - android:layout_width="160dp" - android:layout_height="36dp"/> - <include layout="@layout/view_open_group_invitation" tools:visibility="gone" app:layout_constraintTop_toTopOf="parent" @@ -75,6 +66,15 @@ android:layout_width="wrap_content" android:layout_height="wrap_content"/> + <include layout="@layout/view_voice_message" + app:layout_constraintTop_toBottomOf="@id/quoteView" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + android:visibility="gone" + android:id="@+id/voiceMessageView" + android:layout_width="160dp" + android:layout_height="36dp"/> + <include layout="@layout/view_link_preview" app:layout_constraintTop_toBottomOf="@+id/quoteView" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/menu/menu_conversation_expiration_off.xml b/app/src/main/res/menu/menu_conversation_expiration.xml similarity index 67% rename from app/src/main/res/menu/menu_conversation_expiration_off.xml rename to app/src/main/res/menu/menu_conversation_expiration.xml index 939220009c..e589252237 100644 --- a/app/src/main/res/menu/menu_conversation_expiration_off.xml +++ b/app/src/main/res/menu/menu_conversation_expiration.xml @@ -4,8 +4,7 @@ <item android:title="@string/conversation_expiring_off__disappearing_messages" - android:contentDescription="@string/AccessibilityId_disappearing_messages" - android:id="@+id/menu_expiring_messages_off" - android:icon="@drawable/ic_baseline_timer_off_24" /> + android:id="@+id/menu_expiring_messages" + android:contentDescription="@string/AccessibilityId_disappearing_messages" /> </menu> \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_expiration_on.xml b/app/src/main/res/menu/menu_conversation_expiration_on.xml deleted file mode 100644 index 88775cf71d..0000000000 --- a/app/src/main/res/menu/menu_conversation_expiration_on.xml +++ /dev/null @@ -1,13 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<menu - xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto"> - - <item - android:id="@+id/menu_expiring_messages" - android:contentDescription="@string/AccessibilityId_disappearing_messages_timer" - app:actionLayout="@layout/expiration_timer_menu" - app:showAsAction="always" - android:title="@string/menu_conversation_expiring_on__messages_expiring" /> - -</menu> \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index d8aedf3dea..ae38d0c057 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -319,11 +319,6 @@ <attr name="doc_downloadButtonTint" format="color" /> </declare-styleable> - <declare-styleable name="ConversationItemFooter"> - <attr name="footer_text_color" format="color" /> - <attr name="footer_icon_color" format="color" /> - </declare-styleable> - <declare-styleable name="ConversationItemThumbnail"> <attr name="conversationThumbnail_minWidth" format="dimension" /> <attr name="conversationThumbnail_maxWidth" format="dimension" /> diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 196e587cdc..eae5a2b167 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -165,4 +165,6 @@ <color name="danger_dark">#FF3A3A</color> <color name="danger_light">#E12D19</color> + <color name="outdated_client_banner_background_color">#00F782</color> + </resources> diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 99e0aa197f..4eb1d37fd1 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -2,6 +2,7 @@ <resources> <!-- Font Sizes --> + <dimen name="tiny_font_size">9sp</dimen> <dimen name="very_small_font_size">12sp</dimen> <dimen name="small_font_size">15sp</dimen> <dimen name="medium_font_size">17sp</dimen> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 64c2421dc3..640b9f005d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,7 +56,10 @@ <string name="AccessibilityId_new_conversation_button">New conversation button</string> <string name="AccessibilityId_new_direct_message">New direct message</string> <string name="AccessibilityId_create_group">Create group</string> - <string name="AccessibilityId_join_community">Join community</string> + <string name="AccessibilityId_join_community">Join community button</string> + <!-- Join community pop up --> + <string name="AccessibilityId_community_input_box">Community input</string> + <string name="AccessibilityId_join_community_button">Join community button</string> <!-- Conversation options (three dots menu)--> <string name="AccessibilityId_all_media">All media</string> <string name="AccessibilityId_search">Search</string> @@ -82,14 +85,14 @@ <!-- Conversation icons --> <string name="AccessibilityId_call_button">Call button</string> <string name="AccessibilityId_settings">Settings</string> - <string name="AccessibilityId_disappearing_messages_timer">Disappearing messages timer</string> + <string name="AccessibilityId_confirm">Confirm</string> <string name="AccessibilityId_disappearing_messages_time_picker">Time selector</string> <string name="AccessibilityId_accept_message_request_button">Accept message request</string> <string name="AccessibilityId_decline_message_request_button">Decline message request</string> <string name="AccessibilityId_block_message_request_button">Block message request</string> <string name="AccessibilityId_timer_icon">Timer icon</string> <!-- Configuration messages --> - <string name="AccessibilityId_control_message">Configuration message</string> + <string name="AccessibilityId_control_message">Control message</string> <string name="AccessibilityId_blocked_banner">Blocked banner</string> <string name="AccessibilityId_blocked_banner_text">Blocked banner text</string> <!--New Session --> @@ -114,11 +117,9 @@ <string name="AccessibilityId_download_media">Download media</string> <string name="AccessibilityId_dont_download_media">Don\'t download media</string> <!-- Conversation View--> - <string name="AccessibilityId_message_sent_status_tick">Message sent status: Sent</string> - <string name="AccessibilityId_message_sent_status_pending">Message sent status pending</string> - <string name="AccessibilityId_message_sent_status_syncing">Message sent status syncing</string> + <string name="AccessibilityId_message_sent_status">Message sent status: Sent</string> <string name="AccessibilityId_message_request_config_message">Message request has been accepted</string> - <string name="AccessibilityId_message_body">Message Body</string> + <string name="AccessibilityId_message_body">Message body</string> <string name="AccessibilityId_voice_message">Voice message</string> <string name="AccessibilityId_document">Document</string> <string name="AccessibilityId_deleted_message">Deleted message</string> @@ -153,6 +154,17 @@ <!-- Link preview Dialog--> <string name="AccessibilityId_enable_link_preview_button">Enable</string> <string name="AccessibilityId_cancel_link_preview_button">Cancel</string> + + <!-- Disappearing messages --> + <string name="AccessibilityId_disappear_after_read_option">Disappear after read option</string> + <string name="AccessibilityId_disappear_after_send_option">Disappear after send option</string> + <string name="AccessibilityId_disappearing_messages_timer">Disappearing messages timer</string> + <string name="AccessibilityId_set_button">Set button</string> + <string name="AccessibilityId_time_option">Time option</string> + <string name="AccessibilityId_disable_disappearing_messages">Disable disappearing messages</string> + <string name="AccessibilityId_configuration_message">Configuration message</string> + <string name="AccessibilityId_disappearing_messages_type_and_time">Disappearing messages type and time</string> + <string name="AccessibilityId_conversation_header_name">Conversation header name</string> <!-- AbstractNotificationBuilder --> <string name="AbstractNotificationBuilder_new_message">New message</string> <!-- AlbumThumbnailView --> @@ -467,6 +479,7 @@ <string name="conversation_activity__quick_attachment_drawer_record_and_send_audio_description">Record and send audio attachment</string> <string name="conversation_activity__quick_attachment_drawer_lock_record_description">Lock recording of audio attachment</string> <string name="conversation_activity__enable_signal_for_sms">Enable Session for SMS</string> + <string name="conversation_activity__wait_until_attachment_has_finished_downloading">Please wait until attachment has finished downloading</string> <!-- conversation_input_panel --> <string name="conversation_input_panel__slide_to_cancel">Slide to cancel</string> <string name="conversation_input_panel__cancel">Cancel</string> @@ -556,6 +569,12 @@ <string name="arrays__default">Default</string> <string name="arrays__high">High</string> <string name="arrays__max">Max</string> + <string name="arrays__five_minutes">5 Minutes</string> + <string name="arrays__one_hour">1 Hour</string> + <string name="arrays__twelve_hours">12 Hours</string> + <string name="arrays__one_day">1 Day</string> + <string name="arrays__one_week">1 Week</string> + <string name="arrays__two_weeks">2 Weeks</string> <!-- plurals.xml --> <plurals name="hours_ago"> <item quantity="one">%d hour</item> @@ -890,6 +909,11 @@ <string name="dialog_join_open_group_explanation">Are you sure you want to join the %s open group?</string> <string name="dialog_open_url_title">Open URL?</string> <string name="dialog_open_url_explanation">Are you sure you want to open %s?</string> + <string name="dialog_disappearing_messages_follow_setting_title">Follow Setting</string> + <string name="dialog_disappearing_messages_follow_setting_off_body">Messages you send will no longer disappear. Are you sure you want to turn off disappearing messages?</string> + <string name="dialog_disappearing_messages_follow_setting_on_body">Set your messages to disappear %1$s after they have been %2$s?</string> + <string name="dialog_disappearing_messages_follow_setting_set">Set</string> + <string name="dialog_disappearing_messages_follow_setting_confirm">Confirm</string> <string name="open">Open</string> <string name="copy_url">Copy URL</string> <string name="dialog_link_preview_title">Enable Link Previews?</string> @@ -1023,6 +1047,21 @@ <string name="fragment_enter_community_url_join_button_title">Join</string> <string name="new_conversation_dialog_back_button_content_description">Navigate Back</string> <string name="new_conversation_dialog_close_button_content_description">Close Dialog</string> + <string name="activity_disappearing_messages_title">Disappearing Messages</string> + <string name="activity_disappearing_messages_subtitle">This setting applies to messages you send in this conversation.</string> + <string name="expiration_type_disappear_legacy_description">Original version of disappearing messages.</string> + <string name="expiration_type_disappear_legacy">Legacy</string> + <string name="activity_disappearing_messages_subtitle_sent">Messages disappear after they have been sent.</string> + <string name="expiration_type_disappear_after_read">Disappear After Read</string> + <string name="expiration_type_disappear_after_read_description">Messages delete after they have been read.</string> + <string name="expiration_type_disappear_after_send">Disappear After Send</string> + <string name="expiration_type_disappear_after_send_description">Messages delete after they have been sent.</string> + <string name="disappearing_messages_set_button_title">Set</string> + <string name="activity_disappearing_messages_delete_type">Delete Type</string> + <string name="activity_disappearing_messages_timer">Timer</string> + <string name="activity_disappearing_messages_group_footer">This setting applies to everyone in this conversation.\nOnly group admins can change this setting.</string> + <string name="activity_conversation_outdated_client_banner_text">%s is using an outdated client. Disappearing messages may not work as expected.</string> + <string name="DisappearingMessagesActivity_settings_not_updated">Settings not updated and please try again</string> <string name="ErrorNotifier_migration">Database Upgrade Failed</string> <string name="ErrorNotifier_migration_downgrade">Please contact support to report the error.</string> <string name="delivery_status_syncing">Syncing</string> @@ -1041,5 +1080,6 @@ <string name="activity_conversation_empty_state_default">You have no messages from <b>%s</b>.\nSend a message to start the conversation!</string> <string name="unread_marker">Unread Messages</string> + <string name="auto_deletes_in">Auto-deletes in %1$s</string> </resources> diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 2928fc7718..72dcacf284 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -69,6 +69,10 @@ <item name="android:textColor">@color/emoji_tab_text_color</item> </style> + <style name="TextAppearance.Session.ToolbarSubtitle" parent="TextAppearance.MaterialComponents.Caption"> + <item name="android:textSize">11dp</item> + </style> + <!-- TODO These button styles require proper background selectors for up/down visual state. --> <style name="Widget.Session.Button.Common" parent=""> <item name="android:gravity">center</item> @@ -189,6 +193,17 @@ <item name="android:maxLines">1</item> </style> + <style name="CallMessage"> + <item name="android:background">@drawable/call_message_background</item> + <item name="android:textColor">?message_received_text_color</item> + <item name="android:paddingLeft">@dimen/medium_spacing</item> + <item name="android:paddingTop">12dp</item> + <item name="android:paddingRight">@dimen/medium_spacing</item> + <item name="android:paddingBottom">12dp</item> + <item name="android:textSize">15sp</item> + <item name="android:fontFamily">sans-serif-medium</item> + </style> + <style name="FakeChatViewMessageBubble"> <item name="android:paddingLeft">@dimen/medium_spacing</item> <item name="android:paddingTop">12dp</item> diff --git a/app/src/test/java/org/thoughtcrime/securesms/MainCoroutineRule.kt b/app/src/test/java/org/thoughtcrime/securesms/MainCoroutineRule.kt new file mode 100644 index 0000000000..2e13ce1df6 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/MainCoroutineRule.kt @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@ExperimentalCoroutinesApi +class MainCoroutineRule(private val dispatcher: TestDispatcher = StandardTestDispatcher()) : + TestWatcher() { + + override fun starting(description: Description) { + super.starting(description) + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + super.finished(description) + Dispatchers.resetMain() + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt new file mode 100644 index 0000000000..22679f311e --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt @@ -0,0 +1,579 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages + +import android.app.Application +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupRecord +import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.MainCoroutineRule +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.ExpiryRadioOption +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCard +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.ui.GetString +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +private const val THREAD_ID = 1L +private const val LOCAL_NUMBER = "05---local---address" +private val LOCAL_ADDRESS = Address.fromSerialized(LOCAL_NUMBER) +private const val GROUP_NUMBER = "${GroupUtil.OPEN_GROUP_PREFIX}4133" +private val GROUP_ADDRESS = Address.fromSerialized(GROUP_NUMBER) + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +class DisappearingMessagesViewModelTest { + + @ExperimentalCoroutinesApi + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + @Mock lateinit var application: Application + @Mock lateinit var textSecurePreferences: TextSecurePreferences + @Mock lateinit var messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol + @Mock lateinit var disappearingMessages: DisappearingMessages + @Mock lateinit var threadDb: ThreadDatabase + @Mock lateinit var groupDb: GroupDatabase + @Mock lateinit var storage: Storage + @Mock lateinit var recipient: Recipient + @Mock lateinit var groupRecord: GroupRecord + + @Test + fun `note to self, off, new config`() = runTest { + mock1on1(ExpiryMode.NONE, LOCAL_ADDRESS) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = LOCAL_ADDRESS, + isNoteToSelf = true, + expiryMode = ExpiryMode.NONE, + isNewConfigEnabled = true, + persistedMode = ExpiryMode.NONE, + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_timer, + typeOption(ExpiryMode.NONE, selected = true), + timeOption(ExpiryType.AFTER_SEND, 12.hours), + timeOption(ExpiryType.AFTER_SEND, 1.days), + timeOption(ExpiryType.AFTER_SEND, 7.days), + timeOption(ExpiryType.AFTER_SEND, 14.days) + ) + ) + ) + } + + @Test + fun `note to self, off, old config`() = runTest { + mock1on1(ExpiryMode.NONE, LOCAL_ADDRESS) + + val viewModel = createViewModel(isNewConfigEnabled = false) + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = LOCAL_ADDRESS, + isNoteToSelf = true, + expiryMode = ExpiryMode.Legacy(0), + isNewConfigEnabled = false, + persistedMode = ExpiryMode.Legacy(0), + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_timer, + typeOption(ExpiryMode.NONE, selected = false), + timeOption(ExpiryType.LEGACY, 12.hours), + timeOption(ExpiryType.LEGACY, 1.days), + timeOption(ExpiryType.LEGACY, 7.days), + timeOption(ExpiryType.LEGACY, 14.days) + ) + ) + ) + } + + @Test + fun `group, off, admin, new config`() = runTest { + mockGroup(ExpiryMode.NONE, isAdmin = true) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = true, + isSelfAdmin = true, + address = GROUP_ADDRESS, + isNoteToSelf = false, + expiryMode = ExpiryMode.NONE, + isNewConfigEnabled = true, + persistedMode = ExpiryMode.NONE, + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_timer, + typeOption(ExpiryMode.NONE, selected = true), + timeOption(ExpiryType.AFTER_SEND, 12.hours), + timeOption(ExpiryType.AFTER_SEND, 1.days), + timeOption(ExpiryType.AFTER_SEND, 7.days), + timeOption(ExpiryType.AFTER_SEND, 14.days) + ), + showGroupFooter = true + ) + ) + } + + @Test + fun `group, off, not admin, new config`() = runTest { + mockGroup(ExpiryMode.NONE, isAdmin = false) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = true, + isSelfAdmin = false, + address = GROUP_ADDRESS, + isNoteToSelf = false, + expiryMode = ExpiryMode.NONE, + isNewConfigEnabled = true, + persistedMode = ExpiryMode.NONE, + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_timer, + typeOption(ExpiryMode.NONE, enabled = false, selected = true), + timeOption(ExpiryType.AFTER_SEND, 12.hours, enabled = false), + timeOption(ExpiryType.AFTER_SEND, 1.days, enabled = false), + timeOption(ExpiryType.AFTER_SEND, 7.days, enabled = false), + timeOption(ExpiryType.AFTER_SEND, 14.days, enabled = false) + ), + showGroupFooter = true, + showSetButton = false, + ) + ) + } + + @Test + fun `1-1 conversation, off, new config`() = runTest { + val someAddress = Address.fromSerialized("05---SOME---ADDRESS") + mock1on1(ExpiryMode.NONE, someAddress) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = someAddress, + isNoteToSelf = false, + expiryMode = ExpiryMode.NONE, + isNewConfigEnabled = true, + persistedMode = ExpiryMode.NONE, + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_delete_type, + typeOption(ExpiryMode.NONE, selected = true), + typeOption(12.hours, ExpiryType.AFTER_READ), + typeOption(1.days, ExpiryType.AFTER_SEND) + ) + ) + ) + } + + @Test + fun `1-1 conversation, 12 hours after send, new config`() = runTest { + val time = 12.hours + val someAddress = Address.fromSerialized("05---SOME---ADDRESS") + mock1on1AfterSend(time, someAddress) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = someAddress, + isNoteToSelf = false, + expiryMode = ExpiryMode.AfterSend(12.hours.inWholeSeconds), + isNewConfigEnabled = true, + persistedMode = ExpiryMode.AfterSend(12.hours.inWholeSeconds), + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_delete_type, + typeOption(ExpiryMode.NONE), + typeOption(time, ExpiryType.AFTER_READ), + typeOption(time, ExpiryType.AFTER_SEND, selected = true) + ), + OptionsCard( + R.string.activity_disappearing_messages_timer, + timeOption(ExpiryType.AFTER_SEND, 12.hours, selected = true), + timeOption(ExpiryType.AFTER_SEND, 1.days), + timeOption(ExpiryType.AFTER_SEND, 7.days), + timeOption(ExpiryType.AFTER_SEND, 14.days) + ) + ) + ) + } + + @Test + fun `1-1 conversation, 12 hours legacy, old config`() = runTest { + val time = 12.hours + val someAddress = Address.fromSerialized("05---SOME---ADDRESS") + mock1on1(ExpiryType.LEGACY.mode(time), someAddress) + + val viewModel = createViewModel(isNewConfigEnabled = false) + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = someAddress, + isNoteToSelf = false, + expiryMode = ExpiryType.LEGACY.mode(12.hours), + isNewConfigEnabled = false, + persistedMode = ExpiryType.LEGACY.mode(12.hours), + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_delete_type, + typeOption(ExpiryMode.NONE), + typeOption(time, ExpiryType.LEGACY, selected = true), + typeOption(12.hours, ExpiryType.AFTER_READ, enabled = false), + typeOption(1.days, ExpiryType.AFTER_SEND, enabled = false) + ), + OptionsCard( + R.string.activity_disappearing_messages_timer, + timeOption(ExpiryType.LEGACY, 12.hours, selected = true), + timeOption(ExpiryType.LEGACY, 1.days), + timeOption(ExpiryType.LEGACY, 7.days), + timeOption(ExpiryType.LEGACY, 14.days) + ) + ) + ) + } + + @Test + fun `1-1 conversation, 1 day after send, new config`() = runTest { + val time = 1.days + val someAddress = Address.fromSerialized("05---SOME---ADDRESS") + mock1on1AfterSend(time, someAddress) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = someAddress, + isNoteToSelf = false, + expiryMode = ExpiryMode.AfterSend(1.days.inWholeSeconds), + isNewConfigEnabled = true, + persistedMode = ExpiryMode.AfterSend(1.days.inWholeSeconds), + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_delete_type, + typeOption(ExpiryMode.NONE), + typeOption(12.hours, ExpiryType.AFTER_READ), + typeOption(time, ExpiryType.AFTER_SEND, selected = true) + ), + OptionsCard( + R.string.activity_disappearing_messages_timer, + timeOption(ExpiryType.AFTER_SEND, 12.hours), + timeOption(ExpiryType.AFTER_SEND, 1.days, selected = true), + timeOption(ExpiryType.AFTER_SEND, 7.days), + timeOption(ExpiryType.AFTER_SEND, 14.days) + ) + ) + ) + } + + @Test + fun `1-1 conversation, 1 day after read, new config`() = runTest { + val time = 1.days + val someAddress = Address.fromSerialized("05---SOME---ADDRESS") + + mock1on1AfterRead(time, someAddress) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = someAddress, + isNoteToSelf = false, + expiryMode = ExpiryMode.AfterRead(1.days.inWholeSeconds), + isNewConfigEnabled = true, + persistedMode = ExpiryMode.AfterRead(1.days.inWholeSeconds), + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_delete_type, + typeOption(ExpiryMode.NONE), + typeOption(1.days, ExpiryType.AFTER_READ, selected = true), + typeOption(time, ExpiryType.AFTER_SEND) + ), + OptionsCard( + R.string.activity_disappearing_messages_timer, + timeOption(ExpiryType.AFTER_READ, 5.minutes), + timeOption(ExpiryType.AFTER_READ, 1.hours), + timeOption(ExpiryType.AFTER_READ, 12.hours), + timeOption(ExpiryType.AFTER_READ, 1.days, selected = true), + timeOption(ExpiryType.AFTER_READ, 7.days), + timeOption(ExpiryType.AFTER_READ, 14.days) + ) + ) + ) + } + + @Test + fun `1-1 conversation, init 12 hours after read, then select after send, new config`() = runTest { + val time = 12.hours + val someAddress = Address.fromSerialized("05---SOME---ADDRESS") + + mock1on1AfterRead(time, someAddress) + + val viewModel = createViewModel() + + advanceUntilIdle() + + viewModel.setValue(afterSendMode(1.days)) + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = someAddress, + isNoteToSelf = false, + expiryMode = afterSendMode(1.days), + isNewConfigEnabled = true, + persistedMode = afterReadMode(12.hours), + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_delete_type, + typeOption(ExpiryMode.NONE), + typeOption(12.hours, ExpiryType.AFTER_READ), + typeOption(1.days, ExpiryType.AFTER_SEND, selected = true) + ), + OptionsCard( + R.string.activity_disappearing_messages_timer, + timeOption(ExpiryType.AFTER_SEND, 12.hours), + timeOption(ExpiryType.AFTER_SEND, 1.days, selected = true), + timeOption(ExpiryType.AFTER_SEND, 7.days), + timeOption(ExpiryType.AFTER_SEND, 14.days) + ) + ) + ) + } + + private fun timeOption( + type: ExpiryType, + time: Duration, + enabled: Boolean = true, + selected: Boolean = false + ) = ExpiryRadioOption( + value = type.mode(time), + title = GetString(time), + enabled = enabled, + selected = selected + ) + + private fun afterSendMode(time: Duration) = ExpiryMode.AfterSend(time.inWholeSeconds) + private fun afterReadMode(time: Duration) = ExpiryMode.AfterRead(time.inWholeSeconds) + + private fun mock1on1AfterRead(time: Duration, someAddress: Address) { + mock1on1(ExpiryType.AFTER_READ.mode(time), someAddress) + } + + private fun mock1on1AfterSend(time: Duration, someAddress: Address) { + mock1on1(ExpiryType.AFTER_SEND.mode(time), someAddress) + } + + private fun mock1on1(mode: ExpiryMode, someAddress: Address) { + mockStuff(mode) + + whenever(recipient.address).thenReturn(someAddress) + } + + private fun mockGroup(mode: ExpiryMode, isAdmin: Boolean) { + mockStuff(mode) + + whenever(recipient.address).thenReturn(GROUP_ADDRESS) + whenever(recipient.isClosedGroupRecipient).thenReturn(true) + whenever(groupDb.getGroup(any<String>())).thenReturn(Optional.of(groupRecord)) + whenever(groupRecord.admins).thenReturn( + buildList { + if (isAdmin) add(LOCAL_ADDRESS) + } + ) + } + + private fun mockStuff(mode: ExpiryMode) { + val config = config(mode) + whenever(threadDb.getRecipientForThreadId(Mockito.anyLong())).thenReturn(recipient) + whenever(storage.getExpirationConfiguration(Mockito.anyLong())).thenReturn(config) + whenever(textSecurePreferences.getLocalNumber()).thenReturn(LOCAL_NUMBER) + } + + private fun config(mode: ExpiryMode) = ExpirationConfiguration( + threadId = THREAD_ID, + expiryMode = mode, + updatedTimestampMs = 0 + ) + + private fun createViewModel(isNewConfigEnabled: Boolean = true) = DisappearingMessagesViewModel( + THREAD_ID, + application, + textSecurePreferences, + messageExpirationManager, + disappearingMessages, + threadDb, + groupDb, + storage, + isNewConfigEnabled, + showDebugOptions = false + ) +} + +fun typeOption(time: Duration, type: ExpiryType, selected: Boolean = false, enabled: Boolean = true) = + typeOption(type.mode(time), selected, enabled) + +fun typeOption(mode: ExpiryMode, selected: Boolean = false, enabled: Boolean = true) = + ExpiryRadioOption( + mode, + GetString(mode.type.title), + mode.type.subtitle?.let(::GetString), + GetString(mode.type.contentDescription), + selected = selected, + enabled = enabled + ) diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index aecbd7e4a8..b89c62b6d5 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -10,6 +10,7 @@ import org.hamcrest.CoreMatchers.nullValue import org.hamcrest.MatcherAssert.assertThat import org.junit.Before import org.junit.Test +import org.mockito.Mockito import org.mockito.Mockito.anyLong import org.mockito.Mockito.anySet import org.mockito.Mockito.verify @@ -49,7 +50,8 @@ class ConversationViewModelTest: BaseViewModelTest() { viewModel.saveDraft(draft) - verify(repository).saveDraft(threadId, draft) + // The above is an async process to wait 100ms to give it a chance to complete + verify(repository, Mockito.timeout(100).times(1)).saveDraft(threadId, draft) } @Test diff --git a/build.gradle b/build.gradle index 2cb531a7c1..9ac76c9d07 100644 --- a/build.gradle +++ b/build.gradle @@ -12,11 +12,9 @@ buildscript { dependencies { classpath "com.android.tools.build:gradle:$gradlePluginVersion" - classpath files('libs/gradle-witness.jar') classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion" classpath "com.google.gms:google-services:$googleServicesVersion" - classpath files('libs/gradle-witness.jar') classpath "com.squareup:javapoet:1.13.0" classpath "com.google.dagger:hilt-android-gradle-plugin:$daggerVersion" if (project.hasProperty('huawei')) classpath 'com.huawei.agconnect:agcp:1.9.1.300' @@ -55,12 +53,6 @@ allprojects { includeGroupByRegex "org\\.signal.*" } } - maven { - url "https://repo1.maven.org/maven2/com/goterl/lazysodium-android" - content { - includeGroupByRegex "com\\.goterl.*" - } - } jcenter() maven { url "https://jitpack.io" } if (project.hasProperty('huawei')) maven { @@ -76,7 +68,7 @@ allprojects { project.ext { androidMinimumSdkVersion = 23 - androidTargetSdkVersion = 33 - androidCompileSdkVersion = 33 + androidTargetSdkVersion = 34 + androidCompileSdkVersion = 34 } } \ No newline at end of file diff --git a/liblazysodium/build.gradle b/liblazysodium/build.gradle index 10f52943d8..e53c7ca016 100644 --- a/liblazysodium/build.gradle +++ b/liblazysodium/build.gradle @@ -1,2 +1,2 @@ configurations.maybeCreate("default") -artifacts.add("default", file('lazysodium.aar')) \ No newline at end of file +artifacts.add("default", file('session-lazysodium-android.aar')) \ No newline at end of file diff --git a/liblazysodium/lazysodium.aar b/liblazysodium/lazysodium.aar deleted file mode 100644 index 4227d956e6..0000000000 Binary files a/liblazysodium/lazysodium.aar and /dev/null differ diff --git a/liblazysodium/session-lazysodium-android.aar b/liblazysodium/session-lazysodium-android.aar new file mode 100644 index 0000000000..aadcac7aad Binary files /dev/null and b/liblazysodium/session-lazysodium-android.aar differ diff --git a/libsession-util/build.gradle b/libsession-util/build.gradle index e4f9ed56f3..f368f15eea 100644 --- a/libsession-util/build.gradle +++ b/libsession-util/build.gradle @@ -26,7 +26,7 @@ android { externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" - version "3.22.1" + version "3.22.1+" } } compileOptions { @@ -42,6 +42,6 @@ dependencies { testImplementation 'junit:junit:4.13.2' implementation(project(":libsignal")) implementation "com.google.protobuf:protobuf-java:$protobufVersion" - androidTestImplementation 'androidx.test.ext:junit:1.1.4' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' } \ No newline at end of file diff --git a/libsession-util/libsession-util b/libsession-util/libsession-util index e3ccf29db0..626b6628a2 160000 --- a/libsession-util/libsession-util +++ b/libsession-util/libsession-util @@ -1 +1 @@ -Subproject commit e3ccf29db08aaf0b9bb6bbe72ae5967cd183a78d +Subproject commit 626b6628a2af8fff798042416b3b469b8bfc6ecf diff --git a/libsession-util/src/main/cpp/CMakeLists.txt b/libsession-util/src/main/cpp/CMakeLists.txt index d01523a24e..47fee4803c 100644 --- a/libsession-util/src/main/cpp/CMakeLists.txt +++ b/libsession-util/src/main/cpp/CMakeLists.txt @@ -61,6 +61,7 @@ target_link_libraries( # Specifies the target library. PUBLIC libsession::config libsession::crypto + libsodium::sodium-internal # Links the target library to the log library # included in the NDK. ${log-lib}) diff --git a/libsession-util/src/main/cpp/config_base.cpp b/libsession-util/src/main/cpp/config_base.cpp index eed3ec56af..1c90b1b81c 100644 --- a/libsession-util/src/main/cpp/config_base.cpp +++ b/libsession-util/src/main/cpp/config_base.cpp @@ -82,7 +82,7 @@ Java_network_loki_messenger_libsession_1util_ConfigBase_confirmPushed(JNIEnv *en #pragma clang diagnostic push #pragma ide diagnostic ignored "bugprone-reserved-identifier" -JNIEXPORT jint JNICALL +JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_ConfigBase_merge___3Lkotlin_Pair_2(JNIEnv *env, jobject thiz, jobjectArray to_merge) { std::lock_guard lock{util::util_mutex_}; @@ -94,16 +94,20 @@ Java_network_loki_messenger_libsession_1util_ConfigBase_merge___3Lkotlin_Pair_2( auto pair = extractHashAndData(env, jElement); configs.push_back(pair); } - return conf->merge(configs); + auto returned = conf->merge(configs); + auto string_stack = util::build_string_stack(env, returned); + return string_stack; } -JNIEXPORT jint JNICALL +JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_ConfigBase_merge__Lkotlin_Pair_2(JNIEnv *env, jobject thiz, jobject to_merge) { std::lock_guard lock{util::util_mutex_}; auto conf = ptrToConfigBase(env, thiz); std::vector<std::pair<std::string, session::ustring>> configs = {extractHashAndData(env, to_merge)}; - return conf->merge(configs); + auto returned = conf->merge(configs); + auto string_stack = util::build_string_stack(env, returned); + return string_stack; } #pragma clang diagnostic pop diff --git a/libsession-util/src/main/cpp/contacts.h b/libsession-util/src/main/cpp/contacts.h index c5496a68c8..ecb2cb3749 100644 --- a/libsession-util/src/main/cpp/contacts.h +++ b/libsession-util/src/main/cpp/contacts.h @@ -29,8 +29,7 @@ inline jobject serialize_contact(JNIEnv *env, session::config::contact_info info return returnObj; } -inline session::config::contact_info -deserialize_contact(JNIEnv *env, jobject info, session::config::Contacts *conf) { +inline session::config::contact_info deserialize_contact(JNIEnv *env, jobject info, session::config::Contacts *conf) { jclass contactClass = env->FindClass("network/loki/messenger/libsession_util/util/Contact"); jfieldID getId, getName, getNick, getApproved, getApprovedMe, getBlocked, getUserPic, getPriority, getExpiry, getHidden; diff --git a/libsession-util/src/main/cpp/user_groups.cpp b/libsession-util/src/main/cpp/user_groups.cpp index 4f2b0e6b85..9754b40891 100644 --- a/libsession-util/src/main/cpp/user_groups.cpp +++ b/libsession-util/src/main/cpp/user_groups.cpp @@ -144,10 +144,11 @@ Java_network_loki_messenger_libsession_1util_UserGroupsConfig_erase__Lnetwork_lo auto conf = ptrToUserGroups(env, thiz); auto communityInfo = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$CommunityGroupInfo"); auto legacyInfo = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$LegacyGroupInfo"); - if (env->GetObjectClass(group_info) == communityInfo) { + auto group_object = env->GetObjectClass(group_info); + if (env->IsSameObject(group_object, communityInfo)) { auto deserialized = deserialize_community_info(env, group_info, conf); conf->erase(deserialized); - } else if (env->GetObjectClass(group_info) == legacyInfo) { + } else if (env->IsSameObject(group_object, legacyInfo)) { auto deserialized = deserialize_legacy_group_info(env, group_info, conf); conf->erase(deserialized); } diff --git a/libsession-util/src/main/cpp/user_profile.cpp b/libsession-util/src/main/cpp/user_profile.cpp index 5b3980e634..9f5a9e9d36 100644 --- a/libsession-util/src/main/cpp/user_profile.cpp +++ b/libsession-util/src/main/cpp/user_profile.cpp @@ -96,6 +96,31 @@ Java_network_loki_messenger_libsession_1util_UserProfile_getNtsPriority(JNIEnv * auto profile = ptrToProfile(env, thiz); return profile->get_nts_priority(); } + +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_setNtsExpiry(JNIEnv *env, jobject thiz, + jobject expiry_mode) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto expiry = util::deserialize_expiry(env, expiry_mode); + profile->set_nts_expiry(std::chrono::seconds (expiry.second)); +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_getNtsExpiry(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto nts_expiry = profile->get_nts_expiry(); + if (nts_expiry == std::nullopt) { + auto expiry = util::serialize_expiry(env, session::config::expiration_mode::none, std::chrono::seconds(0)); + return expiry; + } + auto expiry = util::serialize_expiry(env, session::config::expiration_mode::after_send, std::chrono::seconds(*nts_expiry)); + return expiry; +} + extern "C" JNIEXPORT jboolean JNICALL Java_network_loki_messenger_libsession_1util_UserProfile_getCommunityMessageRequests( diff --git a/libsession-util/src/main/cpp/util.cpp b/libsession-util/src/main/cpp/util.cpp index 69469eac1e..602580f04a 100644 --- a/libsession-util/src/main/cpp/util.cpp +++ b/libsession-util/src/main/cpp/util.cpp @@ -96,14 +96,25 @@ namespace util { jclass object_class = env->GetObjectClass(expiry_mode); - if (object_class == after_read) { + if (env->IsSameObject(object_class, after_read)) { return std::pair(session::config::expiration_mode::after_read, env->GetLongField(expiry_mode, duration_seconds)); - } else if (object_class == after_send) { + } else if (env->IsSameObject(object_class, after_send)) { return std::pair(session::config::expiration_mode::after_send, env->GetLongField(expiry_mode, duration_seconds)); } return std::pair(session::config::expiration_mode::none, 0); } + jobject build_string_stack(JNIEnv* env, std::vector<std::string> to_add) { + jclass stack_class = env->FindClass("java/util/Stack"); + jmethodID constructor = env->GetMethodID(stack_class,"<init>", "()V"); + jmethodID add = env->GetMethodID(stack_class, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + jobject our_stack = env->NewObject(stack_class, constructor); + for (std::basic_string_view<char> string: to_add) { + env->CallObjectMethod(our_stack, add, env->NewStringUTF(string.data())); + } + return our_stack; + } + } extern "C" diff --git a/libsession-util/src/main/cpp/util.h b/libsession-util/src/main/cpp/util.h index 9348e8bd7e..0d5189b9c9 100644 --- a/libsession-util/src/main/cpp/util.h +++ b/libsession-util/src/main/cpp/util.h @@ -19,6 +19,7 @@ namespace util { session::config::community deserialize_base_community(JNIEnv *env, jobject base_community); jobject serialize_expiry(JNIEnv *env, const session::config::expiration_mode& mode, const std::chrono::seconds& time_seconds); std::pair<session::config::expiration_mode, long> deserialize_expiry(JNIEnv *env, jobject expiry_mode); + jobject build_string_stack(JNIEnv* env, std::vector<std::string> to_add); } #endif \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt index 7dc41d7fdc..befd0d6d43 100644 --- a/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt @@ -4,11 +4,13 @@ import network.loki.messenger.libsession_util.util.BaseCommunityInfo import network.loki.messenger.libsession_util.util.ConfigPush import network.loki.messenger.libsession_util.util.Contact 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.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import java.util.Stack sealed class ConfigBase(protected val /* yucky */ pointer: Long) { @@ -44,13 +46,13 @@ sealed class ConfigBase(protected val /* yucky */ pointer: Long) { external fun dump(): ByteArray external fun encryptionDomain(): String external fun confirmPushed(seqNo: Long, newHash: String) - external fun merge(toMerge: Array<Pair<String,ByteArray>>): Int + external fun merge(toMerge: Array<Pair<String,ByteArray>>): Stack<String> external fun currentHashes(): List<String> external fun configNamespace(): Int // Singular merge - external fun merge(toMerge: Pair<String,ByteArray>): Int + external fun merge(toMerge: Pair<String,ByteArray>): Stack<String> external fun free() @@ -126,6 +128,8 @@ class UserProfile(pointer: Long) : ConfigBase(pointer) { external fun setPic(userPic: UserPic) external fun setNtsPriority(priority: Int) external fun getNtsPriority(): Int + external fun setNtsExpiry(expiryMode: ExpiryMode) + external fun getNtsExpiry(): ExpiryMode external fun getCommunityMessageRequests(): Boolean external fun setCommunityMessageRequests(blocks: Boolean) external fun isBlockCommunityMessageRequestsSet(): Boolean diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt index 58e98a4392..9761ce5083 100644 --- a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt @@ -1,7 +1,18 @@ package network.loki.messenger.libsession_util.util +import kotlin.time.Duration.Companion.seconds + sealed class ExpiryMode(val expirySeconds: Long) { object NONE: ExpiryMode(0) - class AfterSend(seconds: Long): ExpiryMode(seconds) - class AfterRead(seconds: Long): ExpiryMode(seconds) -} \ No newline at end of file + data class Legacy(private val seconds: Long = 0L): ExpiryMode(seconds) + data class AfterSend(private val seconds: Long = 0L): ExpiryMode(seconds) + data class AfterRead(private val seconds: Long = 0L): ExpiryMode(seconds) + + val duration get() = expirySeconds.seconds + + val expiryMillis get() = expirySeconds * 1000L + + fun coerceSendToRead(coerce: Boolean = true) = if (coerce && this is AfterSend) AfterRead(expirySeconds) else this +} + +fun afterSend(seconds: Long) = seconds.takeIf { it > 0 }?.let(ExpiryMode::AfterSend) ?: ExpiryMode.NONE \ No newline at end of file diff --git a/libsession/build.gradle b/libsession/build.gradle index 3cfb66cd32..55146823ec 100644 --- a/libsession/build.gradle +++ b/libsession/build.gradle @@ -21,7 +21,7 @@ dependencies { implementation project(":libsignal") implementation project(":libsession-util") implementation project(":liblazysodium") - implementation "net.java.dev.jna:jna:5.8.0@aar" + implementation "net.java.dev.jna:jna:5.12.1@aar" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "androidx.core:core-ktx:$coreVersion" implementation "androidx.appcompat:appcompat:$appcompatVersion" @@ -29,8 +29,8 @@ dependencies { implementation "com.google.android.material:material:$materialVersion" implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.google.dagger:hilt-android:$daggerVersion" - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' implementation "com.github.bumptech.glide:glide:$glideVersion" implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1' implementation 'com.annimon:stream:1.1.8' @@ -46,7 +46,7 @@ dependencies { implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" 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.11.0" testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" testImplementation "androidx.test:core:$testCoreVersion" testImplementation "androidx.arch.core:core-testing:2.1.0" diff --git a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt b/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt index 24324ccb7e..52c0b8c7a3 100644 --- a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt +++ b/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt @@ -24,7 +24,7 @@ interface MessageDataProvider { fun deleteMessage(messageID: Long, isSms: Boolean) fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) fun updateMessageAsDeleted(timestamp: Long, author: String): Long? - fun getServerHashForMessage(messageID: Long): String? + fun getServerHashForMessage(messageID: Long, mms: Boolean): String? fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? fun getAttachmentStream(attachmentId: Long): SessionServiceAttachmentStream? fun getAttachmentPointer(attachmentId: Long): SessionServiceAttachmentPointer? diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index 1de94cfe3d..9d6ae18d0c 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -10,6 +10,7 @@ import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.Job import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse @@ -113,7 +114,7 @@ interface StorageProtocol { */ fun persistAttachments(messageID: Long, attachments: List<Attachment>): List<Long> fun getAttachmentsForMessage(messageID: Long): List<DatabaseAttachment> - fun getMessageIdInDatabase(timestamp: Long, author: String): Long? // TODO: This is a weird name + fun getMessageIdInDatabase(timestamp: Long, author: String): Pair<Long, Boolean>? // TODO: This is a weird name fun updateSentTimestamp(messageID: Long, isMms: Boolean, openGroupSentTimestamp: Long, threadId: Long) fun markAsResyncing(timestamp: Long, author: String) fun markAsSyncing(timestamp: Long, author: String) @@ -123,12 +124,12 @@ interface StorageProtocol { fun markAsSyncFailed(timestamp: Long, author: String, error: Exception) fun markAsSentFailed(timestamp: Long, author: String, error: Exception) fun clearErrorMessage(messageID: Long) - fun setMessageServerHash(messageID: Long, serverHash: String) + fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) // Closed Groups fun getGroup(groupID: String): GroupRecord? fun createGroup(groupID: String, title: String?, members: List<Address>, avatar: SignalServiceAttachmentPointer?, relay: String?, admins: List<Address>, formationTimestamp: Long) - fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) + fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair, expirationTimer: Int) fun updateGroupConfig(groupPublicKey: String) fun isGroupActive(groupPublicKey: String): Boolean fun setActive(groupID: String, value: Boolean) @@ -151,7 +152,6 @@ interface StorageProtocol { fun getLatestClosedGroupEncryptionKeyPair(groupPublicKey: String): ECKeyPair? fun updateFormationTimestamp(groupID: String, formationTimestamp: Long) fun updateTimestampUpdated(groupID: String, updatedTimestamp: Long) - fun setExpirationTimer(address: String, duration: Int) // Groups fun getAllGroups(includeInactive: Boolean): List<GroupRecord> @@ -159,7 +159,6 @@ interface StorageProtocol { // Settings fun setProfileSharing(address: Address, value: Boolean) - // Thread fun getOrCreateThreadIdFor(address: Address): Long fun getThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?, createThread: Boolean): Long? @@ -176,6 +175,8 @@ interface StorageProtocol { fun isPinned(threadID: Long): Boolean fun deleteConversation(threadID: Long) fun setThreadDate(threadId: Long, newDate: Long) + fun getLastLegacyRecipient(threadRecipient: String): String? + fun setLastLegacyRecipient(threadRecipient: String, senderRecipient: String?) // Contacts fun getContactWithSessionID(sessionID: String): Contact? @@ -183,7 +184,7 @@ interface StorageProtocol { fun setContact(contact: Contact) fun getRecipientForThread(threadId: Long): Recipient? fun getRecipientSettings(address: Address): RecipientSettings? - fun addLibSessionContacts(contacts: List<LibSessionContact>) + fun addLibSessionContacts(contacts: List<LibSessionContact>, timestamp: Long) fun addContacts(contacts: List<ConfigurationMessage.Contact>) // Attachments @@ -224,9 +225,17 @@ interface StorageProtocol { fun setBlocked(recipients: Iterable<Recipient>, isBlocked: Boolean, fromConfigUpdate: Boolean = false) fun setRecipientHash(recipient: Recipient, recipientHash: String?) fun blockedContacts(): List<Recipient> + fun getExpirationConfiguration(threadId: Long): ExpirationConfiguration? + fun setExpirationConfiguration(config: ExpirationConfiguration) + fun getExpiringMessages(messageIds: List<Long> = emptyList()): List<Pair<Long, Long>> + fun updateDisappearingState( + messageSender: String, + threadID: Long, + disappearingState: Recipient.DisappearingState + ) // Shared configs - fun notifyConfigUpdates(forConfigObject: ConfigBase) + fun notifyConfigUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean fun canPerformConfigChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean fun isCheckingCommunityRequests(): Boolean diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index b9eaf8d50d..6aae0c6c9b 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -35,7 +35,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) override val maxFailureCount: Int = 2 companion object { - val KEY: String = "AttachmentDownloadJob" + const val KEY: String = "AttachmentDownloadJob" // Keys used for database storage private val ATTACHMENT_ID_KEY = "attachment_id" @@ -89,19 +89,21 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) } val threadRecipient = storage.getRecipientForThread(threadID) - val sender = if (messageDataProvider.isMmsOutgoing(databaseMessageID)) { + val selfSend = messageDataProvider.isMmsOutgoing(databaseMessageID) + val sender = if (selfSend) { storage.getUserPublicKey() } else { messageDataProvider.getIndividualRecipientForMms(databaseMessageID)?.address?.serialize() } val contact = sender?.let { storage.getContactWithSessionID(it) } - if (threadRecipient == null || sender == null || contact == null) { + if (threadRecipient == null || sender == null || (contact == null && !selfSend)) { handleFailure(Error.NoSender, null) return } - if (!threadRecipient.isGroupRecipient && (!contact.isTrusted && storage.getUserPublicKey() != sender)) { + if (!threadRecipient.isGroupRecipient && contact?.isTrusted != true && storage.getUserPublicKey() != sender) { // if we aren't receiving a group message, a message from ourselves (self-send) and the contact sending is not trusted: // do not continue, but do not fail + handleFailure(Error.NoSender, null) return } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt index 41301e13b8..02297399b1 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt @@ -34,6 +34,7 @@ import org.session.libsession.utilities.SSKEnvironment import org.session.libsignal.protos.UtilProtos import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import kotlin.math.max data class MessageReceiveParameters( val data: ByteArray, @@ -146,7 +147,7 @@ class BatchMessageReceiveJob( // The LinkedHashMap should preserve insertion order val messageIds = linkedMapOf<Long, Pair<Boolean, Boolean>>() val myLastSeen = storage.getLastSeen(threadId) - var newLastSeen = if (myLastSeen == -1L) 0 else myLastSeen + var newLastSeen = myLastSeen.takeUnless { it == -1L } ?: 0 messages.forEach { (parameters, message, proto) -> try { when (message) { @@ -162,18 +163,14 @@ class BatchMessageReceiveJob( IdPrefix.BLINDED, it.publicKey.asBytes ).hexString } - val sentTimestamp = message.sentTimestamp!! if (message.sender == localUserPublicKey || isUserBlindedSender) { - if (sentTimestamp > newLastSeen) { - newLastSeen = - sentTimestamp // use sent timestamp here since that is technically the last one we have - } + // use sent timestamp here since that is technically the last one we have + newLastSeen = max(newLastSeen, message.sentTimestamp!!) } - val messageId = MessageReceiver.handleVisibleMessage( - message, proto, openGroupID, threadId, + val messageId = MessageReceiver.handleVisibleMessage(message, proto, openGroupID, + threadId, runThreadUpdate = false, - runProfileUpdate = true - ) + runProfileUpdate = true) if (messageId != null && message.reaction == null) { messageIds[messageId] = Pair( @@ -216,9 +213,7 @@ class BatchMessageReceiveJob( // last seen will be the current last seen if not changed (re-computes the read counts for thread record) // might have been updated from a different thread at this point val currentLastSeen = storage.getLastSeen(threadId).let { if (it == -1L) 0 else it } - if (currentLastSeen > newLastSeen) { - newLastSeen = currentLastSeen - } + newLastSeen = max(newLastSeen, currentLastSeen) if (newLastSeen > 0 || currentLastSeen == 0L) { storage.markConversationAsRead(threadId, newLastSeen, force = true) } @@ -247,12 +242,12 @@ class BatchMessageReceiveJob( private fun handleSuccess(dispatcherName: String) { Log.i(TAG, "Completed processing of ${messages.size} messages (id: $id)") - this.delegate?.handleJobSucceeded(this, dispatcherName) + delegate?.handleJobSucceeded(this, dispatcherName) } private fun handleFailure(dispatcherName: String) { Log.i(TAG, "Handling failure of ${failures.size} messages (${messages.size - failures.size} processed successfully) (id: $id)") - this.delegate?.handleJobFailed(this, dispatcherName, Exception("One or more jobs resulted in failure")) + delegate?.handleJobFailed(this, dispatcherName, Exception("One or more jobs resulted in failure")) } override fun serialize(): Data { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index b437808f98..71d62bc72f 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -144,7 +144,7 @@ class JobQueue : JobDelegate { } } else -> { - throw IllegalStateException("Unexpected job type.") + throw IllegalStateException("Unexpected job type: ${job.getFactoryKey()}") } } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/ExpirationConfiguration.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/ExpirationConfiguration.kt new file mode 100644 index 0000000000..8e3ab18b22 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/ExpirationConfiguration.kt @@ -0,0 +1,20 @@ +package org.session.libsession.messaging.messages + +import network.loki.messenger.libsession_util.util.ExpiryMode + +data class ExpirationConfiguration( + val threadId: Long = -1, + val expiryMode: ExpiryMode = ExpiryMode.NONE, + val updatedTimestampMs: Long = 0 +) { + val isEnabled = expiryMode.expirySeconds > 0 + + companion object { + val isNewConfigEnabled = true + } +} + +data class ExpirationDatabaseMetadata( + val threadId: Long = -1, + val updatedTimestampMs: Long +) diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt index dd1d5f1852..5a7ecaedaf 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt @@ -1,11 +1,14 @@ package org.session.libsession.messaging.messages import com.google.protobuf.ByteString +import network.loki.messenger.libsession_util.util.ExpiryMode 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.visible.VisibleMessage import org.session.libsession.utilities.GroupUtil import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType abstract class Message { var id: Long? = null @@ -18,8 +21,14 @@ abstract class Message { var groupPublicKey: String? = null var openGroupServerMessageID: Long? = null var serverHash: String? = null + var specifiedTtl: Long? = null - open val ttl: Long = 14 * 24 * 60 * 60 * 1000 + var expiryMode: ExpiryMode = ExpiryMode.NONE + + open val coerceDisappearAfterSendToRead = false + + open val defaultTtl: Long = 14 * 24 * 60 * 60 * 1000 + open val ttl: Long get() = specifiedTtl ?: defaultTtl open val isSelfSendValid: Boolean = false companion object { @@ -33,22 +42,54 @@ abstract class Message { } } - open fun isValid(): Boolean { - val sentTimestamp = sentTimestamp - if (sentTimestamp != null && sentTimestamp <= 0) { return false } - val receivedTimestamp = receivedTimestamp - if (receivedTimestamp != null && receivedTimestamp <= 0) { return false } - return sender != null && recipient != null - } + open fun isValid(): Boolean = + sentTimestamp?.let { it > 0 } != false + && receivedTimestamp?.let { it > 0 } != false + && sender != null + && recipient != null abstract fun toProto(): SignalServiceProtos.Content? - fun setGroupContext(dataMessage: SignalServiceProtos.DataMessage.Builder) { - val groupProto = SignalServiceProtos.GroupContext.newBuilder() - val groupID = GroupUtil.doubleEncodeGroupID(recipient!!) - groupProto.id = ByteString.copyFrom(GroupUtil.getDecodedGroupIDAsData(groupID)) - groupProto.type = SignalServiceProtos.GroupContext.Type.DELIVER - dataMessage.group = groupProto.build() + fun SignalServiceProtos.DataMessage.Builder.setGroupContext() { + group = SignalServiceProtos.GroupContext.newBuilder().apply { + id = GroupUtil.doubleEncodeGroupID(recipient!!).let(GroupUtil::getDecodedGroupIDAsData).let(ByteString::copyFrom) + type = SignalServiceProtos.GroupContext.Type.DELIVER + }.build() } -} \ No newline at end of file + fun SignalServiceProtos.Content.Builder.applyExpiryMode() = apply { + expirationTimer = expiryMode.expirySeconds.toInt() + expirationType = when (expiryMode) { + is ExpiryMode.AfterSend -> ExpirationType.DELETE_AFTER_SEND + is ExpiryMode.AfterRead -> ExpirationType.DELETE_AFTER_READ + else -> ExpirationType.UNKNOWN + } + } +} + +inline fun <reified M: Message> M.copyExpiration(proto: SignalServiceProtos.Content): M = apply { + (proto.takeIf { it.hasExpirationTimer() }?.expirationTimer ?: proto.dataMessage?.expireTimer)?.let { duration -> + expiryMode = when (proto.expirationType.takeIf { duration > 0 }) { + ExpirationType.DELETE_AFTER_SEND -> ExpiryMode.AfterSend(duration.toLong()) + ExpirationType.DELETE_AFTER_READ -> ExpiryMode.AfterRead(duration.toLong()) + else -> ExpiryMode.NONE + } + } +} + +fun SignalServiceProtos.Content.expiryMode(): ExpiryMode = + (takeIf { it.hasExpirationTimer() }?.expirationTimer ?: dataMessage?.expireTimer)?.let { duration -> + when (expirationType.takeIf { duration > 0 }) { + ExpirationType.DELETE_AFTER_SEND -> ExpiryMode.AfterSend(duration.toLong()) + ExpirationType.DELETE_AFTER_READ -> ExpiryMode.AfterRead(duration.toLong()) + else -> ExpiryMode.NONE + } + } ?: ExpiryMode.NONE + +/** + * Apply ExpiryMode from the current setting. + */ +inline fun <reified M: Message> M.applyExpiryMode(thread: Long): M = apply { + val storage = MessagingModuleConfiguration.shared.storage + expiryMode = storage.getExpirationConfiguration(thread)?.expiryMode?.coerceSendToRead(coerceDisappearAfterSendToRead) ?: ExpiryMode.NONE +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt index 0befa5f4a3..f678271030 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt @@ -1,5 +1,7 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.messaging.messages.applyExpiryMode +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.* import org.session.libsignal.utilities.Log @@ -12,12 +14,13 @@ class CallMessage(): ControlMessage() { var sdpMids: List<String> = listOf() var callId: UUID? = null + override val coerceDisappearAfterSendToRead = true override val isSelfSendValid: Boolean get() = type in arrayOf(ANSWER, END_CALL) - override val ttl: Long = 300000L // 5m + override val defaultTtl: Long = 300000L // 5m override fun isValid(): Boolean = super.isValid() && type != null && callId != null - && (!sdps.isNullOrEmpty() || type in listOf(END_CALL, PRE_OFFER)) + && (sdps.isNotEmpty() || type in listOf(END_CALL, PRE_OFFER)) constructor(type: SignalServiceProtos.CallMessage.Type, sdps: List<String>, @@ -64,7 +67,8 @@ class CallMessage(): ControlMessage() { val sdpMLineIndexes = callMessageProto.sdpMLineIndexesList val sdpMids = callMessageProto.sdpMidsList val callId = UUID.fromString(callMessageProto.uuid) - return CallMessage(type,sdps, sdpMLineIndexes, sdpMids, callId) + return CallMessage(type, sdps, sdpMLineIndexes, sdpMids, callId) + .copyExpiration(proto) } } @@ -82,9 +86,8 @@ class CallMessage(): ControlMessage() { .setUuid(callId!!.toString()) return SignalServiceProtos.Content.newBuilder() - .setCallMessage( - callMessage - ) + .applyExpiryMode() + .setCallMessage(callMessage) .build() } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ClosedGroupControlMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ClosedGroupControlMessage.kt index c7aa03a7b2..101baac2d0 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ClosedGroupControlMessage.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ClosedGroupControlMessage.kt @@ -6,15 +6,22 @@ import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos.DataMessage -import org.session.libsignal.utilities.removingIdPrefixIfNeeded -import org.session.libsignal.utilities.toHexString +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.ClosedGroupControlMessage.Type.ENCRYPTION_KEY_PAIR +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.ClosedGroupControlMessage.Type.MEMBERS_ADDED +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.ClosedGroupControlMessage.Type.MEMBERS_REMOVED +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.ClosedGroupControlMessage.Type.MEMBER_LEFT +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.ClosedGroupControlMessage.Type.NAME_CHANGE +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.ClosedGroupControlMessage.Type.NEW import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.removingIdPrefixIfNeeded +import org.session.libsignal.utilities.toHexString class ClosedGroupControlMessage() : ControlMessage() { var kind: Kind? = null + var groupID: String? = null - override val ttl: Long get() { + override val defaultTtl: Long get() { return when (kind) { is Kind.EncryptionKeyPair -> 14 * 24 * 60 * 60 * 1000 else -> 14 * 24 * 60 * 60 * 1000 @@ -76,50 +83,30 @@ class ClosedGroupControlMessage() : ControlMessage() { companion object { const val TAG = "ClosedGroupControlMessage" - fun fromProto(proto: SignalServiceProtos.Content): ClosedGroupControlMessage? { - if (!proto.hasDataMessage() || !proto.dataMessage.hasClosedGroupControlMessage()) return null - val closedGroupControlMessageProto = proto.dataMessage!!.closedGroupControlMessage!! - val kind: Kind - when (closedGroupControlMessageProto.type!!) { - DataMessage.ClosedGroupControlMessage.Type.NEW -> { - val publicKey = closedGroupControlMessageProto.publicKey ?: return null - val name = closedGroupControlMessageProto.name ?: return null - val encryptionKeyPairAsProto = closedGroupControlMessageProto.encryptionKeyPair ?: return null - val expirationTimer = closedGroupControlMessageProto.expirationTimer - try { - val encryptionKeyPair = ECKeyPair(DjbECPublicKey(encryptionKeyPairAsProto.publicKey.toByteArray()), - DjbECPrivateKey(encryptionKeyPairAsProto.privateKey.toByteArray())) - kind = Kind.New(publicKey, name, encryptionKeyPair, closedGroupControlMessageProto.membersList, closedGroupControlMessageProto.adminsList, expirationTimer) - } catch (e: Exception) { - Log.w(TAG, "Couldn't parse key pair from proto: $encryptionKeyPairAsProto.") - return null - } - } - DataMessage.ClosedGroupControlMessage.Type.ENCRYPTION_KEY_PAIR -> { - val publicKey = closedGroupControlMessageProto.publicKey - val wrappers = closedGroupControlMessageProto.wrappersList.mapNotNull { KeyPairWrapper.fromProto(it) } - kind = Kind.EncryptionKeyPair(publicKey, wrappers) - } - DataMessage.ClosedGroupControlMessage.Type.NAME_CHANGE -> { - val name = closedGroupControlMessageProto.name ?: return null - kind = Kind.NameChange(name) - } - DataMessage.ClosedGroupControlMessage.Type.MEMBERS_ADDED -> { - kind = Kind.MembersAdded(closedGroupControlMessageProto.membersList) - } - DataMessage.ClosedGroupControlMessage.Type.MEMBERS_REMOVED -> { - kind = Kind.MembersRemoved(closedGroupControlMessageProto.membersList) - } - DataMessage.ClosedGroupControlMessage.Type.MEMBER_LEFT -> { - kind = Kind.MemberLeft() - } - } - return ClosedGroupControlMessage(kind) - } + fun fromProto(proto: SignalServiceProtos.Content): ClosedGroupControlMessage? = + proto.takeIf { it.hasDataMessage() }?.dataMessage + ?.takeIf { it.hasClosedGroupControlMessage() }?.closedGroupControlMessage + ?.run { + when (type) { + NEW -> takeIf { it.hasPublicKey() && it.hasEncryptionKeyPair() && it.hasName() }?.let { + ECKeyPair( + DjbECPublicKey(encryptionKeyPair.publicKey.toByteArray()), + DjbECPrivateKey(encryptionKeyPair.privateKey.toByteArray()) + ).let { Kind.New(publicKey, name, it, membersList, adminsList, expirationTimer) } + } + ENCRYPTION_KEY_PAIR -> Kind.EncryptionKeyPair(publicKey, wrappersList.mapNotNull(KeyPairWrapper::fromProto)) + NAME_CHANGE -> takeIf { it.hasName() }?.let { Kind.NameChange(name) } + MEMBERS_ADDED -> Kind.MembersAdded(membersList) + MEMBERS_REMOVED -> Kind.MembersRemoved(membersList) + MEMBER_LEFT -> Kind.MemberLeft() + else -> null + }?.let(::ClosedGroupControlMessage) + } } - internal constructor(kind: Kind?) : this() { + internal constructor(kind: Kind?, groupID: String? = null) : this() { this.kind = kind + this.groupID = groupID } override fun toProto(): SignalServiceProtos.Content? { @@ -132,45 +119,44 @@ class ClosedGroupControlMessage() : ControlMessage() { val closedGroupControlMessage: DataMessage.ClosedGroupControlMessage.Builder = DataMessage.ClosedGroupControlMessage.newBuilder() when (kind) { is Kind.New -> { - closedGroupControlMessage.type = DataMessage.ClosedGroupControlMessage.Type.NEW + closedGroupControlMessage.type = NEW closedGroupControlMessage.publicKey = kind.publicKey closedGroupControlMessage.name = kind.name - val encryptionKeyPair = SignalServiceProtos.KeyPair.newBuilder() - encryptionKeyPair.publicKey = ByteString.copyFrom(kind.encryptionKeyPair!!.publicKey.serialize().removingIdPrefixIfNeeded()) - encryptionKeyPair.privateKey = ByteString.copyFrom(kind.encryptionKeyPair!!.privateKey.serialize()) - closedGroupControlMessage.encryptionKeyPair = encryptionKeyPair.build() + closedGroupControlMessage.encryptionKeyPair = SignalServiceProtos.KeyPair.newBuilder().also { + it.publicKey = ByteString.copyFrom(kind.encryptionKeyPair!!.publicKey.serialize().removingIdPrefixIfNeeded()) + it.privateKey = ByteString.copyFrom(kind.encryptionKeyPair!!.privateKey.serialize()) + }.build() closedGroupControlMessage.addAllMembers(kind.members) closedGroupControlMessage.addAllAdmins(kind.admins) closedGroupControlMessage.expirationTimer = kind.expirationTimer } is Kind.EncryptionKeyPair -> { - closedGroupControlMessage.type = DataMessage.ClosedGroupControlMessage.Type.ENCRYPTION_KEY_PAIR + closedGroupControlMessage.type = ENCRYPTION_KEY_PAIR closedGroupControlMessage.publicKey = kind.publicKey ?: ByteString.EMPTY closedGroupControlMessage.addAllWrappers(kind.wrappers.map { it.toProto() }) } is Kind.NameChange -> { - closedGroupControlMessage.type = DataMessage.ClosedGroupControlMessage.Type.NAME_CHANGE + closedGroupControlMessage.type = NAME_CHANGE closedGroupControlMessage.name = kind.name } is Kind.MembersAdded -> { - closedGroupControlMessage.type = DataMessage.ClosedGroupControlMessage.Type.MEMBERS_ADDED + closedGroupControlMessage.type = MEMBERS_ADDED closedGroupControlMessage.addAllMembers(kind.members) } is Kind.MembersRemoved -> { - closedGroupControlMessage.type = DataMessage.ClosedGroupControlMessage.Type.MEMBERS_REMOVED + closedGroupControlMessage.type = MEMBERS_REMOVED closedGroupControlMessage.addAllMembers(kind.members) } is Kind.MemberLeft -> { - closedGroupControlMessage.type = DataMessage.ClosedGroupControlMessage.Type.MEMBER_LEFT + closedGroupControlMessage.type = MEMBER_LEFT } } - val contentProto = SignalServiceProtos.Content.newBuilder() - val dataMessageProto = DataMessage.newBuilder() - dataMessageProto.closedGroupControlMessage = closedGroupControlMessage.build() - // Group context - setGroupContext(dataMessageProto) - contentProto.dataMessage = dataMessageProto.build() - return contentProto.build() + return SignalServiceProtos.Content.newBuilder().apply { + dataMessage = DataMessage.newBuilder().also { + it.closedGroupControlMessage = closedGroupControlMessage.build() + it.setGroupContext() + }.build() + }.build() } catch (e: Exception) { Log.w(TAG, "Couldn't construct closed group control message proto from: $this.") return null @@ -191,11 +177,9 @@ class ClosedGroupControlMessage() : ControlMessage() { } fun toProto(): DataMessage.ClosedGroupControlMessage.KeyPairWrapper? { - val publicKey = publicKey ?: return null - val encryptedKeyPair = encryptedKeyPair ?: return null val result = DataMessage.ClosedGroupControlMessage.KeyPairWrapper.newBuilder() - result.publicKey = ByteString.copyFrom(Hex.fromStringCondensed(publicKey)) - result.encryptedKeyPair = encryptedKeyPair + result.publicKey = ByteString.copyFrom(Hex.fromStringCondensed(publicKey ?: return null)) + result.encryptedKeyPair = encryptedKeyPair ?: return null return try { result.build() } catch (e: Exception) { @@ -204,4 +188,4 @@ class ClosedGroupControlMessage() : ControlMessage() { } } } -} \ No newline at end of file +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt index eae9a76730..7544ff9c82 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt @@ -2,28 +2,28 @@ package org.session.libsession.messaging.messages.control import com.google.protobuf.ByteString import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.ProfileKeyUtil -import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.TextSecurePreferences 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.protos.SignalServiceProtos +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.removingIdPrefixIfNeeded import org.session.libsignal.utilities.toHexString -import org.session.libsignal.utilities.Hex class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups: List<String>, var contacts: List<Contact>, var displayName: String, var profilePicture: String?, var profileKey: ByteArray) : ControlMessage() { override val isSelfSendValid: Boolean = true - class ClosedGroup(var publicKey: String, var name: String, var encryptionKeyPair: ECKeyPair?, var members: List<String>, var admins: List<String>, var expirationTimer: Int) { + class ClosedGroup(var publicKey: String, var name: String, var encryptionKeyPair: ECKeyPair?, var members: List<String>, var admins: List<String>) { val isValid: Boolean get() = members.isNotEmpty() && admins.isNotEmpty() - internal constructor() : this("", "", null, listOf(), listOf(), 0) + internal constructor() : this("", "", null, listOf(), listOf()) override fun toString(): String { return name @@ -40,8 +40,7 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups: DjbECPrivateKey(encryptionKeyPairAsProto.privateKey.toByteArray())) val members = proto.membersList.map { it.toByteArray().toHexString() } val admins = proto.adminsList.map { it.toByteArray().toHexString() } - val expirationTimer = proto.expirationTimer - return ClosedGroup(publicKey, name, encryptionKeyPair, members, admins, expirationTimer) + return ClosedGroup(publicKey, name, encryptionKeyPair, members, admins) } } @@ -55,7 +54,6 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups: result.encryptionKeyPair = encryptionKeyPairAsProto.build() result.addAllMembers(members.map { ByteString.copyFrom(Hex.fromStringCondensed(it)) }) result.addAllAdmins(admins.map { ByteString.copyFrom(Hex.fromStringCondensed(it)) }) - result.expirationTimer = expirationTimer return result.build() } } @@ -128,8 +126,13 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups: if (!group.members.contains(Address.fromSerialized(storage.getUserPublicKey()!!))) continue val groupPublicKey = GroupUtil.doubleDecodeGroupID(group.encodedId).toHexString() val encryptionKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: continue - val recipient = Recipient.from(context, Address.fromSerialized(group.encodedId), false) - val closedGroup = ClosedGroup(groupPublicKey, group.title, encryptionKeyPair, group.members.map { it.serialize() }, group.admins.map { it.serialize() }, recipient.expireMessages) + val closedGroup = ClosedGroup( + groupPublicKey, + group.title, + encryptionKeyPair, + group.members.map { it.serialize() }, + group.admins.map { it.serialize() } + ) closedGroups.add(closedGroup) } if (group.isOpenGroup) { @@ -152,6 +155,7 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups: val profileKey = configurationProto.profileKey val contacts = configurationProto.contactsList.mapNotNull { Contact.fromProto(it) } return ConfigurationMessage(closedGroups, openGroups, contacts, displayName, profilePicture, profileKey.toByteArray()) + .copyExpiration(proto) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt index a637b26326..7ac009b6a6 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt @@ -1,11 +1,14 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log class DataExtractionNotification() : ControlMessage() { var kind: Kind? = null + override val coerceDisappearAfterSendToRead = true + sealed class Kind { class Screenshot() : Kind() class MediaSaved(val timestamp: Long) : Kind() @@ -31,6 +34,7 @@ class DataExtractionNotification() : ControlMessage() { } } return DataExtractionNotification(kind) + .copyExpiration(proto) } } @@ -62,9 +66,10 @@ class DataExtractionNotification() : ControlMessage() { dataExtractionNotification.timestamp = kind.timestamp } } - val contentProto = SignalServiceProtos.Content.newBuilder() - contentProto.dataExtractionNotification = dataExtractionNotification.build() - return contentProto.build() + return SignalServiceProtos.Content.newBuilder() + .setDataExtractionNotification(dataExtractionNotification.build()) + .applyExpiryMode() + .build() } catch (e: Exception) { Log.w(TAG, "Couldn't construct data extraction notification proto from: $this") return null diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt index e03a92e134..58df8aa7b5 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt @@ -1,77 +1,52 @@ package org.session.libsession.messaging.messages.control import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsignal.utilities.Log +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE +import org.session.libsignal.utilities.Log -class ExpirationTimerUpdate() : ControlMessage() { - /** In the case of a sync message, the public key of the person the message was targeted at. - * - * **Note:** `nil` if this isn't a sync message. - */ - var syncTarget: String? = null - var duration: Int? = 0 - +/** In the case of a sync message, the public key of the person the message was targeted at. + * + * **Note:** `nil` if this isn't a sync message. + */ +data class ExpirationTimerUpdate(var syncTarget: String? = null, val isGroup: Boolean = false) : ControlMessage() { override val isSelfSendValid: Boolean = true - override fun isValid(): Boolean { - if (!super.isValid()) return false - return duration != null - } - companion object { const val TAG = "ExpirationTimerUpdate" + private val storage = MessagingModuleConfiguration.shared.storage - fun fromProto(proto: SignalServiceProtos.Content): ExpirationTimerUpdate? { - val dataMessageProto = if (proto.hasDataMessage()) proto.dataMessage else return null - val isExpirationTimerUpdate = dataMessageProto.flags.and(SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE) != 0 - if (!isExpirationTimerUpdate) return null - val syncTarget = dataMessageProto.syncTarget - val duration = dataMessageProto.expireTimer - return ExpirationTimerUpdate(syncTarget, duration) - } - } - - constructor(duration: Int) : this() { - this.syncTarget = null - this.duration = duration - } - - internal constructor(syncTarget: String, duration: Int) : this() { - this.syncTarget = syncTarget - this.duration = duration + fun fromProto(proto: SignalServiceProtos.Content): ExpirationTimerUpdate? = + proto.dataMessage?.takeIf { it.flags and EXPIRATION_TIMER_UPDATE_VALUE != 0 }?.run { + ExpirationTimerUpdate(takeIf { hasSyncTarget() }?.syncTarget, hasGroup()).copyExpiration(proto) + } } override fun toProto(): SignalServiceProtos.Content? { - val duration = duration - if (duration == null) { - Log.w(TAG, "Couldn't construct expiration timer update proto from: $this") - return null + val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder().apply { + flags = EXPIRATION_TIMER_UPDATE_VALUE + expireTimer = expiryMode.expirySeconds.toInt() } - val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder() - dataMessageProto.flags = SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE - dataMessageProto.expireTimer = duration // Sync target - if (syncTarget != null) { - dataMessageProto.syncTarget = syncTarget - } + syncTarget?.let { dataMessageProto.syncTarget = it } // Group context - if (MessagingModuleConfiguration.shared.storage.isClosedGroup(recipient!!)) { + if (storage.isClosedGroup(recipient!!)) { try { - setGroupContext(dataMessageProto) + dataMessageProto.setGroupContext() } catch(e: Exception) { - Log.w(VisibleMessage.TAG, "Couldn't construct visible message proto from: $this") + Log.w(TAG, "Couldn't construct visible message proto from: $this", e) return null } } - val contentProto = SignalServiceProtos.Content.newBuilder() - try { - contentProto.dataMessage = dataMessageProto.build() - return contentProto.build() + return try { + SignalServiceProtos.Content.newBuilder() + .setDataMessage(dataMessageProto) + .applyExpiryMode() + .build() } catch (e: Exception) { - Log.w(TAG, "Couldn't construct expiration timer update proto from: $this") - return null + Log.w(TAG, "Couldn't construct expiration timer update proto from: $this", e) + null } } -} \ No newline at end of file +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt index 614a6eb811..df30b9cb74 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt @@ -1,6 +1,7 @@ package org.session.libsession.messaging.messages.control import com.google.protobuf.ByteString +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsession.messaging.messages.visible.Profile import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log @@ -19,6 +20,7 @@ class MessageRequestResponse(val isApproved: Boolean, var profile: Profile? = nu profile?.profileKey?.let { messageRequestResponseProto.profileKey = ByteString.copyFrom(it) } return try { SignalServiceProtos.Content.newBuilder() + .applyExpiryMode() .setMessageRequestResponse(messageRequestResponseProto.build()) .build() } catch (e: Exception) { @@ -40,7 +42,7 @@ class MessageRequestResponse(val isApproved: Boolean, var profile: Profile? = nu profilePictureURL = profileProto.profilePicture } return MessageRequestResponse(isApproved, profile) + .copyExpiration(proto) } } - -} \ No newline at end of file +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt index e0c7b690bc..e635c3c0cf 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt @@ -1,5 +1,6 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log @@ -22,10 +23,11 @@ class ReadReceipt() : ControlMessage() { val timestamps = receiptProto.timestampList if (timestamps.isEmpty()) return null return ReadReceipt(timestamps = timestamps) + .copyExpiration(proto) } } - internal constructor(timestamps: List<Long>?) : this() { + constructor(timestamps: List<Long>?) : this() { this.timestamps = timestamps } @@ -35,16 +37,18 @@ class ReadReceipt() : ControlMessage() { Log.w(TAG, "Couldn't construct read receipt proto from: $this") return null } - val receiptProto = SignalServiceProtos.ReceiptMessage.newBuilder() - receiptProto.type = SignalServiceProtos.ReceiptMessage.Type.READ - receiptProto.addAllTimestamp(timestamps.asIterable()) - val contentProto = SignalServiceProtos.Content.newBuilder() - try { - contentProto.receiptMessage = receiptProto.build() - return contentProto.build() + + return try { + SignalServiceProtos.Content.newBuilder() + .setReceiptMessage( + SignalServiceProtos.ReceiptMessage.newBuilder() + .setType(SignalServiceProtos.ReceiptMessage.Type.READ) + .addAllTimestamp(timestamps.asIterable()).build() + ).applyExpiryMode() + .build() } catch (e: Exception) { Log.w(TAG, "Couldn't construct read receipt proto from: $this") - return null + null } } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt index 72b2474965..7ec4eaa6f0 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt @@ -10,12 +10,10 @@ class SharedConfigurationMessage(val kind: SharedConfigMessage.Kind, val data: B override val isSelfSendValid: Boolean = true companion object { - fun fromProto(proto: SignalServiceProtos.Content): SharedConfigurationMessage? { - if (!proto.hasSharedConfigMessage()) return null - val sharedConfig = proto.sharedConfigMessage - if (!sharedConfig.hasKind() || !sharedConfig.hasData()) return null - return SharedConfigurationMessage(sharedConfig.kind, sharedConfig.data.toByteArray(), sharedConfig.seqno) - } + fun fromProto(proto: SignalServiceProtos.Content): SharedConfigurationMessage? = + proto.takeIf { it.hasSharedConfigMessage() }?.sharedConfigMessage + ?.takeIf { it.hasKind() && it.hasData() } + ?.run { SharedConfigurationMessage(kind, data.toByteArray(), seqno) } } override fun isValid(): Boolean { diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt index c755751ba6..343bb63544 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt @@ -1,12 +1,13 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log class TypingIndicator() : ControlMessage() { var kind: Kind? = null - override val ttl: Long = 20 * 1000 + override val defaultTtl: Long = 20 * 1000 override fun isValid(): Boolean { if (!super.isValid()) return false @@ -20,6 +21,7 @@ class TypingIndicator() : ControlMessage() { val typingIndicatorProto = if (proto.hasTypingMessage()) proto.typingMessage else return null val kind = Kind.fromProto(typingIndicatorProto.action) return TypingIndicator(kind = kind) + .copyExpiration(proto) } } @@ -54,16 +56,14 @@ class TypingIndicator() : ControlMessage() { Log.w(TAG, "Couldn't construct typing indicator proto from: $this") return null } - val typingIndicatorProto = SignalServiceProtos.TypingMessage.newBuilder() - typingIndicatorProto.timestamp = timestamp - typingIndicatorProto.action = kind.toProto() - val contentProto = SignalServiceProtos.Content.newBuilder() - try { - contentProto.typingMessage = typingIndicatorProto.build() - return contentProto.build() + return try { + SignalServiceProtos.Content.newBuilder() + .setTypingMessage(SignalServiceProtos.TypingMessage.newBuilder().setTimestamp(timestamp).setAction(kind.toProto()).build()) + .applyExpiryMode() + .build() } catch (e: Exception) { Log.w(TAG, "Couldn't construct typing indicator proto from: $this") - return null + null } } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt index a22dd93348..a8fd327285 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt @@ -1,11 +1,10 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log -class UnsendRequest(): ControlMessage() { - var timestamp: Long? = null - var author: String? = null +class UnsendRequest(var timestamp: Long? = null, var author: String? = null): ControlMessage() { override val isSelfSendValid: Boolean = true @@ -19,17 +18,8 @@ class UnsendRequest(): ControlMessage() { companion object { const val TAG = "UnsendRequest" - fun fromProto(proto: SignalServiceProtos.Content): UnsendRequest? { - val unsendRequestProto = if (proto.hasUnsendRequest()) proto.unsendRequest else return null - val timestamp = unsendRequestProto.timestamp - val author = unsendRequestProto.author - return UnsendRequest(timestamp, author) - } - } - - constructor(timestamp: Long, author: String) : this() { - this.timestamp = timestamp - this.author = author + fun fromProto(proto: SignalServiceProtos.Content): UnsendRequest? = + proto.takeIf { it.hasUnsendRequest() }?.unsendRequest?.run { UnsendRequest(timestamp, author) }?.copyExpiration(proto) } override fun toProto(): SignalServiceProtos.Content? { @@ -39,16 +29,14 @@ class UnsendRequest(): ControlMessage() { Log.w(TAG, "Couldn't construct unsend request proto from: $this") return null } - val unsendRequestProto = SignalServiceProtos.UnsendRequest.newBuilder() - unsendRequestProto.timestamp = timestamp - unsendRequestProto.author = author - val contentProto = SignalServiceProtos.Content.newBuilder() - try { - contentProto.unsendRequest = unsendRequestProto.build() - return contentProto.build() + return try { + SignalServiceProtos.Content.newBuilder() + .setUnsendRequest(SignalServiceProtos.UnsendRequest.newBuilder().setTimestamp(timestamp).setAuthor(author).build()) + .applyExpiryMode() + .build() } catch (e: Exception) { Log.w(TAG, "Couldn't construct unsend request proto from: $this") - return null + null } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java index ab24234e81..ce668d0369 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java @@ -26,6 +26,7 @@ public class IncomingMediaMessage { private final long sentTimeMillis; private final int subscriptionId; private final long expiresIn; + private final long expireStartedAt; private final boolean expirationUpdate; private final boolean unidentified; private final boolean messageRequestResponse; @@ -42,6 +43,7 @@ public class IncomingMediaMessage { long sentTimeMillis, int subscriptionId, long expiresIn, + long expireStartedAt, boolean expirationUpdate, boolean unidentified, boolean messageRequestResponse, @@ -60,6 +62,7 @@ public class IncomingMediaMessage { this.body = body.orNull(); this.subscriptionId = subscriptionId; this.expiresIn = expiresIn; + this.expireStartedAt = expireStartedAt; this.expirationUpdate = expirationUpdate; this.dataExtractionNotification = dataExtractionNotification.orNull(); this.quote = quote.orNull(); @@ -78,12 +81,13 @@ public class IncomingMediaMessage { public static IncomingMediaMessage from(VisibleMessage message, Address from, long expiresIn, + long expireStartedAt, Optional<SignalServiceGroup> group, List<SignalServiceAttachment> attachments, Optional<QuoteModel> quote, Optional<List<LinkPreview>> linkPreviews) { - return new IncomingMediaMessage(from, message.getSentTimestamp(), -1, expiresIn, false, + return new IncomingMediaMessage(from, message.getSentTimestamp(), -1, expiresIn, expireStartedAt, false, false, false, message.getHasMention(), Optional.fromNullable(message.getText()), group, Optional.fromNullable(attachments), quote, Optional.absent(), linkPreviews, Optional.absent()); } @@ -124,6 +128,10 @@ public class IncomingMediaMessage { return expiresIn; } + public long getExpireStartedAt() { + return expireStartedAt; + } + public boolean isGroupMessage() { return groupId != null; } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java index ca8e89f1e0..3395a06237 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java @@ -41,6 +41,7 @@ public class IncomingTextMessage implements Parcelable { private final boolean push; private final int subscriptionId; private final long expiresInMillis; + private final long expireStartedAt; private final boolean unidentified; private final int callType; private final boolean hasMention; @@ -49,19 +50,19 @@ public class IncomingTextMessage implements Parcelable { public IncomingTextMessage(Address sender, int senderDeviceId, long sentTimestampMillis, String encodedBody, Optional<SignalServiceGroup> group, - long expiresInMillis, boolean unidentified, boolean hasMention) { - this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, unidentified, -1, hasMention); + long expiresInMillis, long expireStartedAt, boolean unidentified, boolean hasMention) { + this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, expireStartedAt, unidentified, -1, hasMention); } public IncomingTextMessage(Address sender, int senderDeviceId, long sentTimestampMillis, String encodedBody, Optional<SignalServiceGroup> group, - long expiresInMillis, boolean unidentified, int callType, boolean hasMention) { - this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, unidentified, callType, hasMention, true); + long expiresInMillis, long expireStartedAt, boolean unidentified, int callType, boolean hasMention) { + this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, expireStartedAt, unidentified, callType, hasMention, true); } public IncomingTextMessage(Address sender, int senderDeviceId, long sentTimestampMillis, String encodedBody, Optional<SignalServiceGroup> group, - long expiresInMillis, boolean unidentified, int callType, boolean hasMention, boolean isPush) { + long expiresInMillis, long expireStartedAt, boolean unidentified, int callType, boolean hasMention, boolean isPush) { this.message = encodedBody; this.sender = sender; this.senderDeviceId = senderDeviceId; @@ -73,6 +74,7 @@ public class IncomingTextMessage implements Parcelable { this.push = isPush; this.subscriptionId = -1; this.expiresInMillis = expiresInMillis; + this.expireStartedAt = expireStartedAt; this.unidentified = unidentified; this.callType = callType; this.hasMention = hasMention; @@ -97,6 +99,7 @@ public class IncomingTextMessage implements Parcelable { this.push = (in.readInt() == 1); this.subscriptionId = in.readInt(); this.expiresInMillis = in.readLong(); + this.expireStartedAt = in.readLong(); this.unidentified = in.readInt() == 1; this.isOpenGroupInvitation = in.readInt() == 1; this.callType = in.readInt(); @@ -116,6 +119,7 @@ public class IncomingTextMessage implements Parcelable { this.push = base.isPush(); this.subscriptionId = base.getSubscriptionId(); this.expiresInMillis = base.getExpiresIn(); + this.expireStartedAt = base.getExpireStartedAt(); this.unidentified = base.isUnidentified(); this.isOpenGroupInvitation = base.isOpenGroupInvitation(); this.callType = base.callType; @@ -125,19 +129,23 @@ public class IncomingTextMessage implements Parcelable { public static IncomingTextMessage from(VisibleMessage message, Address sender, Optional<SignalServiceGroup> group, - long expiresInMillis) + long expiresInMillis, + long expireStartedAt) { - return new IncomingTextMessage(sender, 1, message.getSentTimestamp(), message.getText(), group, expiresInMillis, false, message.getHasMention()); + return new IncomingTextMessage(sender, 1, message.getSentTimestamp(), message.getText(), group, expiresInMillis, expireStartedAt, false, message.getHasMention()); } - public static IncomingTextMessage fromOpenGroupInvitation(OpenGroupInvitation openGroupInvitation, Address sender, Long sentTimestamp) - { + public static IncomingTextMessage fromOpenGroupInvitation(OpenGroupInvitation openGroupInvitation, + Address sender, + Long sentTimestamp, + long expiresInMillis, + long expireStartedAt) { String url = openGroupInvitation.getUrl(); String name = openGroupInvitation.getName(); if (url == null || name == null) { return null; } // FIXME: Doing toJSON() to get the body here is weird String body = UpdateMessageData.Companion.buildOpenGroupInvitation(url, name).toJSON(); - IncomingTextMessage incomingTextMessage = new IncomingTextMessage(sender, 1, sentTimestamp, body, Optional.absent(), 0, false, false); + IncomingTextMessage incomingTextMessage = new IncomingTextMessage(sender, 1, sentTimestamp, body, Optional.absent(), expiresInMillis, expireStartedAt, false, false); incomingTextMessage.isOpenGroupInvitation = true; return incomingTextMessage; } @@ -145,8 +153,10 @@ public class IncomingTextMessage implements Parcelable { public static IncomingTextMessage fromCallInfo(CallMessageType callMessageType, Address sender, Optional<SignalServiceGroup> group, - long sentTimestamp) { - return new IncomingTextMessage(sender, 1, sentTimestamp, null, group, 0, false, callMessageType.ordinal(), false, false); + long sentTimestamp, + long expiresInMillis, + long expireStartedAt) { + return new IncomingTextMessage(sender, 1, sentTimestamp, null, group, expiresInMillis, expireStartedAt, false, callMessageType.ordinal(), false, false); } public int getSubscriptionId() { @@ -157,6 +167,10 @@ public class IncomingTextMessage implements Parcelable { return expiresInMillis; } + public long getExpireStartedAt() { + return expireStartedAt; + } + public long getSentTimestampMillis() { return sentTimestampMillis; } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingExpirationUpdateMessage.java b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingExpirationUpdateMessage.java index fe1a4ff009..b42d6d1bde 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingExpirationUpdateMessage.java +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingExpirationUpdateMessage.java @@ -11,9 +11,9 @@ public class OutgoingExpirationUpdateMessage extends OutgoingSecureMediaMessage private final String groupId; - public OutgoingExpirationUpdateMessage(Recipient recipient, long sentTimeMillis, long expiresIn, String groupId) { + public OutgoingExpirationUpdateMessage(Recipient recipient, long sentTimeMillis, long expiresIn, long expireStartedAt, String groupId) { super(recipient, "", new LinkedList<Attachment>(), sentTimeMillis, - DistributionTypes.CONVERSATION, expiresIn, null, Collections.emptyList(), + DistributionTypes.CONVERSATION, expiresIn, expireStartedAt, null, Collections.emptyList(), Collections.emptyList()); this.groupId = groupId; } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java index 43eac8dea9..86db70f435 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java @@ -24,6 +24,7 @@ public class OutgoingGroupMediaMessage extends OutgoingSecureMediaMessage { @Nullable final Attachment avatar, long sentTime, long expireIn, + long expireStartedAt, boolean updateMessage, @Nullable QuoteModel quote, @NonNull List<Contact> contacts, @@ -32,7 +33,7 @@ public class OutgoingGroupMediaMessage extends OutgoingSecureMediaMessage { super(recipient, body, new LinkedList<Attachment>() {{if (avatar != null) add(avatar);}}, sentTime, - DistributionTypes.CONVERSATION, expireIn, quote, contacts, previews); + DistributionTypes.CONVERSATION, expireIn, expireStartedAt, quote, contacts, previews); this.groupID = groupId; this.isUpdateMessage = updateMessage; diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java index 08cb2c4b02..85dc0cc6a2 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java @@ -4,13 +4,13 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.session.libsession.messaging.messages.visible.VisibleMessage; +import org.session.libsession.messaging.sending_receiving.attachments.Attachment; +import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; +import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel; +import org.session.libsession.utilities.Contact; import org.session.libsession.utilities.DistributionTypes; import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.NetworkFailure; -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; -import org.session.libsession.utilities.Contact; -import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; -import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel; import org.session.libsession.utilities.recipients.Recipient; import java.util.Collections; @@ -26,6 +26,7 @@ public class OutgoingMediaMessage { private final int distributionType; private final int subscriptionId; private final long expiresIn; + private final long expireStartedAt; private final QuoteModel outgoingQuote; private final List<NetworkFailure> networkFailures = new LinkedList<>(); @@ -35,7 +36,7 @@ public class OutgoingMediaMessage { public OutgoingMediaMessage(Recipient recipient, String message, List<Attachment> attachments, long sentTimeMillis, - int subscriptionId, long expiresIn, + int subscriptionId, long expiresIn, long expireStartedAt, int distributionType, @Nullable QuoteModel outgoingQuote, @NonNull List<Contact> contacts, @@ -50,6 +51,7 @@ public class OutgoingMediaMessage { this.attachments = attachments; this.subscriptionId = subscriptionId; this.expiresIn = expiresIn; + this.expireStartedAt = expireStartedAt; this.outgoingQuote = outgoingQuote; this.contacts.addAll(contacts); @@ -66,6 +68,7 @@ public class OutgoingMediaMessage { this.sentTimeMillis = that.sentTimeMillis; this.subscriptionId = that.subscriptionId; this.expiresIn = that.expiresIn; + this.expireStartedAt = that.expireStartedAt; this.outgoingQuote = that.outgoingQuote; this.identityKeyMismatches.addAll(that.identityKeyMismatches); @@ -78,14 +81,16 @@ public class OutgoingMediaMessage { Recipient recipient, List<Attachment> attachments, @Nullable QuoteModel outgoingQuote, - @Nullable LinkPreview linkPreview) + @Nullable LinkPreview linkPreview, + long expiresInMillis, + long expireStartedAt) { List<LinkPreview> previews = Collections.emptyList(); if (linkPreview != null) { previews = Collections.singletonList(linkPreview); } return new OutgoingMediaMessage(recipient, message.getText(), attachments, message.getSentTimestamp(), -1, - recipient.getExpireMessages() * 1000, DistributionTypes.DEFAULT, outgoingQuote, + expiresInMillis, expireStartedAt, DistributionTypes.DEFAULT, outgoingQuote, Collections.emptyList(), previews, Collections.emptyList(), Collections.emptyList()); } @@ -123,6 +128,10 @@ public class OutgoingMediaMessage { return expiresIn; } + public long getExpireStartedAt() { + return expireStartedAt; + } + public @Nullable QuoteModel getOutgoingQuote() { return outgoingQuote; } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java index 907d1a9dda..e93c3c5986 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java @@ -19,11 +19,12 @@ public class OutgoingSecureMediaMessage extends OutgoingMediaMessage { long sentTimeMillis, int distributionType, long expiresIn, + long expireStartedAt, @Nullable QuoteModel quote, @NonNull List<Contact> contacts, @NonNull List<LinkPreview> previews) { - super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, distributionType, quote, contacts, previews, Collections.emptyList(), Collections.emptyList()); + super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, expireStartedAt, distributionType, quote, contacts, previews, Collections.emptyList(), Collections.emptyList()); } public OutgoingSecureMediaMessage(OutgoingMediaMessage base) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java index abc39b662e..b62a75a1e3 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java @@ -10,28 +10,30 @@ public class OutgoingTextMessage { private final String message; private final int subscriptionId; private final long expiresIn; + private final long expireStartedAt; private final long sentTimestampMillis; private boolean isOpenGroupInvitation = false; - public OutgoingTextMessage(Recipient recipient, String message, long expiresIn, int subscriptionId, long sentTimestampMillis) { + public OutgoingTextMessage(Recipient recipient, String message, long expiresIn, long expireStartedAt, int subscriptionId, long sentTimestampMillis) { this.recipient = recipient; this.message = message; this.expiresIn = expiresIn; + this.expireStartedAt= expireStartedAt; this.subscriptionId = subscriptionId; this.sentTimestampMillis = sentTimestampMillis; } - public static OutgoingTextMessage from(VisibleMessage message, Recipient recipient) { - return new OutgoingTextMessage(recipient, message.getText(), recipient.getExpireMessages() * 1000, -1, message.getSentTimestamp()); + public static OutgoingTextMessage from(VisibleMessage message, Recipient recipient, long expiresInMillis, long expireStartedAt) { + return new OutgoingTextMessage(recipient, message.getText(), expiresInMillis, expireStartedAt, -1, message.getSentTimestamp()); } - public static OutgoingTextMessage fromOpenGroupInvitation(OpenGroupInvitation openGroupInvitation, Recipient recipient, Long sentTimestamp) { + public static OutgoingTextMessage fromOpenGroupInvitation(OpenGroupInvitation openGroupInvitation, Recipient recipient, Long sentTimestamp, long expiresInMillis, long expireStartedAt) { String url = openGroupInvitation.getUrl(); String name = openGroupInvitation.getName(); if (url == null || name == null) { return null; } // FIXME: Doing toJSON() to get the body here is weird String body = UpdateMessageData.Companion.buildOpenGroupInvitation(url, name).toJSON(); - OutgoingTextMessage outgoingTextMessage = new OutgoingTextMessage(recipient, body, 0, -1, sentTimestamp); + OutgoingTextMessage outgoingTextMessage = new OutgoingTextMessage(recipient, body, expiresInMillis, expireStartedAt, -1, sentTimestamp); outgoingTextMessage.isOpenGroupInvitation = true; return outgoingTextMessage; } @@ -40,6 +42,10 @@ public class OutgoingTextMessage { return expiresIn; } + public long getExpireStartedAt() { + return expireStartedAt; + } + public int getSubscriptionId() { return subscriptionId; } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt index ce6b61524c..9c95bbcfbd 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt @@ -4,10 +4,11 @@ import com.google.protobuf.ByteString import org.session.libsignal.utilities.Log import org.session.libsignal.protos.SignalServiceProtos -class Profile() { - var displayName: String? = null - var profileKey: ByteArray? = null +class Profile( + var displayName: String? = null, + var profileKey: ByteArray? = null, var profilePictureURL: String? = null +) { companion object { const val TAG = "Profile" @@ -25,12 +26,6 @@ class Profile() { } } - constructor(displayName: String, profileKey: ByteArray? = null, profilePictureURL: String? = null) : this() { - this.displayName = displayName - this.profileKey = profileKey - this.profilePictureURL = profilePictureURL - } - fun toProto(): SignalServiceProtos.DataMessage? { val displayName = displayName if (displayName == null) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt index d6c067930d..7ced3836a3 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt @@ -3,10 +3,8 @@ package org.session.libsession.messaging.messages.visible import com.goterl.lazysodium.BuildConfig import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.GroupUtil -import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment @@ -16,7 +14,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment * * **Note:** `nil` if this isn't a sync message. */ -class VisibleMessage( +data class VisibleMessage( var syncTarget: String? = null, var text: String? = null, val attachmentIDs: MutableList<Long> = mutableListOf(), @@ -46,52 +44,26 @@ class VisibleMessage( companion object { const val TAG = "VisibleMessage" - fun fromProto(proto: SignalServiceProtos.Content): VisibleMessage? { - val dataMessage = proto.dataMessage ?: return null - val result = VisibleMessage() - if (dataMessage.hasSyncTarget()) { result.syncTarget = dataMessage.syncTarget } - result.text = dataMessage.body - // Attachments are handled in MessageReceiver - val quoteProto = if (dataMessage.hasQuote()) dataMessage.quote else null - if (quoteProto != null) { - val quote = Quote.fromProto(quoteProto) - result.quote = quote - } - val linkPreviewProto = dataMessage.previewList.firstOrNull() - if (linkPreviewProto != null) { - val linkPreview = LinkPreview.fromProto(linkPreviewProto) - result.linkPreview = linkPreview - } - val openGroupInvitationProto = if (dataMessage.hasOpenGroupInvitation()) dataMessage.openGroupInvitation else null - if (openGroupInvitationProto != null) { - val openGroupInvitation = OpenGroupInvitation.fromProto(openGroupInvitationProto) - result.openGroupInvitation = openGroupInvitation - } - // TODO Contact - val profile = Profile.fromProto(dataMessage) - if (profile != null) { result.profile = profile } - val reactionProto = if (dataMessage.hasReaction()) dataMessage.reaction else null - if (reactionProto != null) { - val reaction = Reaction.fromProto(reactionProto) - result.reaction = reaction - } - - result.blocksMessageRequests = with (dataMessage) { hasBlocksCommunityMessageRequests() && blocksCommunityMessageRequests } - - return result + fun fromProto(proto: SignalServiceProtos.Content): VisibleMessage? = + proto.dataMessage?.let { VisibleMessage().apply { + if (it.hasSyncTarget()) syncTarget = it.syncTarget + text = it.body + // Attachments are handled in MessageReceiver + if (it.hasQuote()) quote = Quote.fromProto(it.quote) + linkPreview = it.previewList.firstOrNull()?.let(LinkPreview::fromProto) + if (it.hasOpenGroupInvitation()) openGroupInvitation = it.openGroupInvitation?.let(OpenGroupInvitation::fromProto) + // TODO Contact + profile = Profile.fromProto(it) + if (it.hasReaction()) reaction = it.reaction?.let(Reaction::fromProto) + blocksMessageRequests = it.hasBlocksCommunityMessageRequests() && it.blocksCommunityMessageRequests + }.copyExpiration(proto) } } override fun toProto(): SignalServiceProtos.Content? { val proto = SignalServiceProtos.Content.newBuilder() - val dataMessage: SignalServiceProtos.DataMessage.Builder // Profile - val profileProto = profile?.toProto() - dataMessage = if (profileProto != null) { - profileProto.toBuilder() - } else { - SignalServiceProtos.DataMessage.newBuilder() - } + val dataMessage = profile?.toProto()?.toBuilder() ?: SignalServiceProtos.DataMessage.newBuilder() // Text if (text != null) { dataMessage.body = text } // Quote @@ -126,20 +98,12 @@ class VisibleMessage( dataMessage.addAllAttachments(pointers) // TODO: Contact // Expiration timer - // TODO: We * want * expiration timer updates to be explicit. But currently Android will disable the expiration timer for a conversation - // if it receives a message without the current expiration timer value attached to it... - val storage = MessagingModuleConfiguration.shared.storage - val context = MessagingModuleConfiguration.shared.context - val expiration = if (storage.isClosedGroup(recipient!!)) { - Recipient.from(context, Address.fromSerialized(GroupUtil.doubleEncodeGroupID(recipient!!)), false).expireMessages - } else { - Recipient.from(context, Address.fromSerialized(recipient!!), false).expireMessages - } - dataMessage.expireTimer = expiration + proto.applyExpiryMode() // Group context + val storage = MessagingModuleConfiguration.shared.storage if (storage.isClosedGroup(recipient!!)) { try { - setGroupContext(dataMessage) + dataMessage.setGroupContext() } catch (e: Exception) { Log.w(TAG, "Couldn't construct visible message proto from: $this") return null @@ -173,4 +137,4 @@ class VisibleMessage( fun isMediaMessage(): Boolean { return attachmentIDs.isNotEmpty() || quote != null || linkPreview != null || reaction != null } -} \ No newline at end of file +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index c6b6186c9c..a7b5051f51 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -973,5 +973,17 @@ object OpenGroupApi { } } + fun deleteAllInboxMessages(server: String): Promise<Map<*, *>, java.lang.Exception> { + val request = Request( + verb = DELETE, + room = null, + server = server, + endpoint = Endpoint.Inbox + ) + return getResponseBody(request).map { response -> + JsonUtil.fromJson(response, Map::class.java) + } + } + // endregion } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt index 34022b7396..8d9d69fb82 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt @@ -38,11 +38,13 @@ object MessageReceiver { object NoGroupThread: Error("No thread exists for this group.") object NoGroupKeyPair: Error("Missing group key pair.") object NoUserED25519KeyPair : Error("Couldn't find user ED25519 key pair.") + object ExpiredMessage: Error("Message has already expired, prevent adding") internal val isRetryable: Boolean = when (this) { is DuplicateMessage, is InvalidMessage, is UnknownMessage, is UnknownEnvelopeType, is InvalidSignature, is NoData, - is SenderBlocked, is SelfSend, is NoGroupThread -> false + is SenderBlocked, is SelfSend, + is ExpiredMessage, is NoGroupThread -> false else -> true } } @@ -143,15 +145,14 @@ object MessageReceiver { MessageRequestResponse.fromProto(proto) ?: CallMessage.fromProto(proto) ?: SharedConfigurationMessage.fromProto(proto) ?: - VisibleMessage.fromProto(proto) ?: run { - throw Error.UnknownMessage - } + VisibleMessage.fromProto(proto) ?: throw Error.UnknownMessage + val isUserBlindedSender = sender == openGroupPublicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!) }?.let { SessionId(IdPrefix.BLINDED, it.publicKey.asBytes).hexString } - // Ignore self send if needed - if (!message.isSelfSendValid && (sender == userPublicKey || isUserBlindedSender)) { - throw Error.SelfSend - } - if (sender == userPublicKey || isUserBlindedSender) { + val isUserSender = sender == userPublicKey + + if (isUserSender || isUserBlindedSender) { + // Ignore self send if needed + if (!message.isSelfSendValid) throw Error.SelfSend message.isSenderSelf = true } // Guard against control messages in open groups @@ -191,4 +192,5 @@ object MessageReceiver { // Return return Pair(message, proto) } + } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index e7929bc350..21df7fcb5d 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -1,5 +1,6 @@ package org.session.libsession.messaging.sending_receiving +import network.loki.messenger.libsession_util.util.ExpiryMode import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred import org.session.libsession.messaging.MessagingModuleConfiguration @@ -8,6 +9,7 @@ import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.jobs.NotifyPNServerJob import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage import org.session.libsession.messaging.messages.control.ConfigurationMessage @@ -26,6 +28,7 @@ import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.snode.RawResponsePromise import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.SnodeModule import org.session.libsession.utilities.Address @@ -34,7 +37,12 @@ import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.SSKEnvironment import org.session.libsignal.crypto.PushTransportDetails import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.* +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Namespace +import org.session.libsignal.utilities.defaultRequiresAuth +import org.session.libsignal.utilities.hasNamespaces +import org.session.libsignal.utilities.hexEncodedPublicKey import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment @@ -77,14 +85,14 @@ object MessageSender { val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey() // Set the timestamp, sender and recipient - val messageSendTime = SnodeAPI.nowWithOffset + val messageSendTime = nowWithOffset if (message.sentTimestamp == null) { message.sentTimestamp = messageSendTime // Visible messages will already have their sent timestamp set } message.sender = userPublicKey - + // SHARED CONFIG when (destination) { is Destination.Contact -> message.recipient = destination.publicKey is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey @@ -155,7 +163,7 @@ object MessageSender { return SnodeMessage( message.recipient!!, base64EncodedData, - message.ttl, + ttl = getSpecifiedTtl(message, isSyncMessage) ?: message.ttl, messageSendTime ) } @@ -185,8 +193,13 @@ object MessageSender { val namespaces: List<Int> = when { destination is Destination.ClosedGroup && forkInfo.defaultRequiresAuth() -> listOf(Namespace.UNAUTHENTICATED_CLOSED_GROUP) + destination is Destination.ClosedGroup - && forkInfo.hasNamespaces() -> listOf(Namespace.UNAUTHENTICATED_CLOSED_GROUP, Namespace.DEFAULT) + && forkInfo.hasNamespaces() -> listOf( + Namespace.UNAUTHENTICATED_CLOSED_GROUP, + Namespace.DEFAULT + ) + else -> listOf(Namespace.DEFAULT) } namespaces.map { namespace -> SnodeAPI.sendMessage(snodeMessage, requiresAuth = false, namespace = namespace) }.let { promises -> @@ -238,13 +251,26 @@ object MessageSender { return promise } + private fun getSpecifiedTtl( + message: Message, + isSyncMessage: Boolean + ): Long? = message.takeUnless { it is ClosedGroupControlMessage }?.run { + threadID ?: (if (isSyncMessage && this is VisibleMessage) syncTarget else recipient) + ?.let(Address.Companion::fromSerialized) + ?.let(MessagingModuleConfiguration.shared.storage::getThreadId) + }?.let(MessagingModuleConfiguration.shared.storage::getExpirationConfiguration) + ?.takeIf { it.isEnabled } + ?.expiryMode + ?.takeIf { it is ExpiryMode.AfterSend || isSyncMessage } + ?.expiryMillis + // Open Groups private fun sendToOpenGroupDestination(destination: Destination, message: Message): Promise<Unit, Exception> { val deferred = deferred<Unit, Exception>() val storage = MessagingModuleConfiguration.shared.storage val configFactory = MessagingModuleConfiguration.shared.configFactory if (message.sentTimestamp == null) { - message.sentTimestamp = SnodeAPI.nowWithOffset + message.sentTimestamp = nowWithOffset } // Attach the blocks message requests info configFactory.user?.let { user -> @@ -347,20 +373,23 @@ object MessageSender { fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) { val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey()!! + val timestamp = message.sentTimestamp!! // Ignore future self-sends - storage.addReceivedMessageTimestamp(message.sentTimestamp!!) - storage.getMessageIdInDatabase(message.sentTimestamp!!, userPublicKey)?.let { messageID -> + storage.addReceivedMessageTimestamp(timestamp) + storage.getMessageIdInDatabase(timestamp, userPublicKey)?.let { (messageID, mms) -> if (openGroupSentTimestamp != -1L && message is VisibleMessage) { storage.addReceivedMessageTimestamp(openGroupSentTimestamp) storage.updateSentTimestamp(messageID, message.isMediaMessage(), openGroupSentTimestamp, message.threadID!!) message.sentTimestamp = openGroupSentTimestamp } + // When the sync message is successfully sent, the hash value of this TSOutgoingMessage // will be replaced by the hash value of the sync message. Since the hash value of the // real message has no use when we delete a message. It is OK to let it be. message.serverHash?.let { - storage.setMessageServerHash(messageID, it) + storage.setMessageServerHash(messageID, mms, it) } + // in case any errors from previous sends storage.clearErrorMessage(messageID) // Track the open group server message ID @@ -387,12 +416,10 @@ object MessageSender { } } // Mark the message as sent - storage.markAsSent(message.sentTimestamp!!, userPublicKey) - storage.markUnidentified(message.sentTimestamp!!, userPublicKey) + storage.markAsSent(timestamp, userPublicKey) + storage.markUnidentified(timestamp, userPublicKey) // Start the disappearing messages timer if needed - if (message is VisibleMessage && !isSyncMessage) { - SSKEnvironment.shared.messageExpirationManager.startAnyExpiration(message.sentTimestamp!!, userPublicKey) - } + SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(message, startDisappearAfterRead = true) } ?: run { storage.updateReactionIfNeeded(message, message.sender?:userPublicKey, openGroupSentTimestamp) } @@ -404,7 +431,7 @@ object MessageSender { if (message is VisibleMessage) message.syncTarget = destination.publicKey if (message is ExpirationTimerUpdate) message.syncTarget = destination.publicKey - storage.markAsSyncing(message.sentTimestamp!!, userPublicKey) + storage.markAsSyncing(timestamp, userPublicKey) sendToSnodeDestination(Destination.Contact(userPublicKey), message, true) } } @@ -431,7 +458,7 @@ object MessageSender { message.linkPreview?.let { linkPreview -> if (linkPreview.attachmentID == null) { messageDataProvider.getLinkPreviewAttachmentIDFor(message.id!!)?.let { attachmentID -> - message.linkPreview!!.attachmentID = attachmentID + linkPreview.attachmentID = attachmentID message.attachmentIDs.remove(attachmentID) } } @@ -442,6 +469,7 @@ object MessageSender { @JvmStatic fun send(message: Message, address: Address) { val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) + threadID?.let(message::applyExpiryMode) message.threadID = threadID val destination = Destination.from(address) val job = MessageSendJob(message, destination) diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt index 1a7e3d8eaa..e30d58b939 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt @@ -16,7 +16,6 @@ import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Device import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.crypto.ecc.Curve import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceGroup @@ -27,7 +26,7 @@ import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.removingIdPrefixIfNeeded -import java.util.* +import java.util.LinkedList import java.util.concurrent.ConcurrentHashMap const val groupSizeLimit = 100 @@ -54,7 +53,7 @@ fun MessageSender.create( val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) val admins = setOf( userPublicKey ) val adminsAsData = admins.map { ByteString.copyFrom(Hex.fromStringCondensed(it)) } - storage.createGroup(groupID, name, LinkedList(members.map { Address.fromSerialized(it) }), + storage.createGroup(groupID, name, LinkedList(members.map { fromSerialized(it) }), null, null, LinkedList(admins.map { Address.fromSerialized(it) }), SnodeAPI.nowWithOffset) storage.setProfileSharing(Address.fromSerialized(groupID), true) @@ -75,7 +74,7 @@ fun MessageSender.create( val ourPubKey = storage.getUserPublicKey() for (member in members) { - val closedGroupControlMessage = ClosedGroupControlMessage(closedGroupUpdateKind) + val closedGroupControlMessage = ClosedGroupControlMessage(closedGroupUpdateKind, groupID) closedGroupControlMessage.sentTimestamp = sentTime try { sendNonDurably(closedGroupControlMessage, Address.fromSerialized(member), member == ourPubKey).get() @@ -92,7 +91,7 @@ fun MessageSender.create( } // Add the group to the config now that it was successfully created - storage.createInitialConfigGroup(groupPublicKey, name, GroupUtil.createConfigMemberMap(members, admins), sentTime, encryptionKeyPair) + storage.createInitialConfigGroup(groupPublicKey, name, GroupUtil.createConfigMemberMap(members, admins), sentTime, encryptionKeyPair, 0) // Notify the PN server PushRegistryV1.register(device = device, publicKey = userPublicKey) // Start polling @@ -117,7 +116,7 @@ fun MessageSender.setName(groupPublicKey: String, newName: String) { // Send the update to the group val kind = ClosedGroupControlMessage.Kind.NameChange(newName) val sentTime = SnodeAPI.nowWithOffset - val closedGroupControlMessage = ClosedGroupControlMessage(kind) + val closedGroupControlMessage = ClosedGroupControlMessage(kind, groupID) closedGroupControlMessage.sentTimestamp = sentTime send(closedGroupControlMessage, Address.fromSerialized(groupID)) // Update the group @@ -136,8 +135,8 @@ fun MessageSender.addMembers(groupPublicKey: String, membersToAdd: List<String>) Log.d("Loki", "Can't add members to nonexistent closed group.") throw Error.NoThread } - val recipient = Recipient.from(context, fromSerialized(groupID), false) - val expireTimer = recipient.expireMessages + val threadId = storage.getOrCreateThreadIdFor(fromSerialized(groupID)) + val expireTimer = storage.getExpirationConfiguration(threadId)?.expiryMode?.expirySeconds ?: 0 if (membersToAdd.isEmpty()) { Log.d("Loki", "Invalid closed group update.") throw Error.InvalidClosedGroupUpdate @@ -157,13 +156,20 @@ fun MessageSender.addMembers(groupPublicKey: String, membersToAdd: List<String>) // Send the update to the group val memberUpdateKind = ClosedGroupControlMessage.Kind.MembersAdded(newMembersAsData) val sentTime = SnodeAPI.nowWithOffset - val closedGroupControlMessage = ClosedGroupControlMessage(memberUpdateKind) + val closedGroupControlMessage = ClosedGroupControlMessage(memberUpdateKind, groupID) closedGroupControlMessage.sentTimestamp = sentTime send(closedGroupControlMessage, Address.fromSerialized(groupID)) // Send closed group update messages to any new members individually for (member in membersToAdd) { - val closedGroupNewKind = ClosedGroupControlMessage.Kind.New(ByteString.copyFrom(Hex.fromStringCondensed(groupPublicKey)), name, encryptionKeyPair, membersAsData, adminsAsData, expireTimer) - val closedGroupControlMessage = ClosedGroupControlMessage(closedGroupNewKind) + val closedGroupNewKind = ClosedGroupControlMessage.Kind.New( + ByteString.copyFrom(Hex.fromStringCondensed(groupPublicKey)), + name, + encryptionKeyPair, + membersAsData, + adminsAsData, + expireTimer.toInt() + ) + val closedGroupControlMessage = ClosedGroupControlMessage(closedGroupNewKind, groupID) // It's important that the sent timestamp of this message is greater than the sent timestamp // of the `MembersAdded` message above. The reason is that upon receiving this `New` message, // the recipient will update the closed group formation timestamp and ignore any closed group @@ -212,7 +218,7 @@ fun MessageSender.removeMembers(groupPublicKey: String, membersToRemove: List<St // Send the update to the group val memberUpdateKind = ClosedGroupControlMessage.Kind.MembersRemoved(removeMembersAsData) val sentTime = SnodeAPI.nowWithOffset - val closedGroupControlMessage = ClosedGroupControlMessage(memberUpdateKind) + val closedGroupControlMessage = ClosedGroupControlMessage(memberUpdateKind, groupID) closedGroupControlMessage.sentTimestamp = sentTime send(closedGroupControlMessage, Address.fromSerialized(groupID)) // Send the new encryption key pair to the remaining group members. @@ -241,7 +247,7 @@ fun MessageSender.leave(groupPublicKey: String, notifyUser: Boolean = true): Pro val admins = group.admins.map { it.serialize() } val name = group.title // Send the update to the group - val closedGroupControlMessage = ClosedGroupControlMessage(ClosedGroupControlMessage.Kind.MemberLeft()) + val closedGroupControlMessage = ClosedGroupControlMessage(ClosedGroupControlMessage.Kind.MemberLeft(), groupID) val sentTime = SnodeAPI.nowWithOffset closedGroupControlMessage.sentTimestamp = sentTime storage.setActive(groupID, false) @@ -302,7 +308,7 @@ fun MessageSender.sendEncryptionKeyPair(groupPublicKey: String, newKeyPair: ECKe } val kind = ClosedGroupControlMessage.Kind.EncryptionKeyPair(ByteString.copyFrom(Hex.fromStringCondensed(groupPublicKey)), wrappers) val sentTime = SnodeAPI.nowWithOffset - val closedGroupControlMessage = ClosedGroupControlMessage(kind) + val closedGroupControlMessage = ClosedGroupControlMessage(kind, null) closedGroupControlMessage.sentTimestamp = sentTime return if (force) { val isSync = MessagingModuleConfiguration.shared.storage.getUserPublicKey() == destination @@ -337,6 +343,6 @@ fun MessageSender.sendLatestEncryptionKeyPair(publicKey: String, groupPublicKey: Log.d("Loki", "Sending latest encryption key pair to: $publicKey.") val wrapper = ClosedGroupControlMessage.KeyPairWrapper(publicKey, ByteString.copyFrom(ciphertext)) val kind = ClosedGroupControlMessage.Kind.EncryptionKeyPair(ByteString.copyFrom(Hex.fromStringCondensed(groupPublicKey)), listOf(wrapper)) - val closedGroupControlMessage = ClosedGroupControlMessage(kind) + val closedGroupControlMessage = ClosedGroupControlMessage(kind, groupID) MessageSender.send(closedGroupControlMessage, Address.fromSerialized(publicKey)) } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 8745418e77..6be9c5b058 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -2,10 +2,13 @@ package org.session.libsession.messaging.sending_receiving import android.text.TextUtils import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.avatars.AvatarHelper import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.messages.ExpirationConfiguration.Companion.isNewConfigEnabled import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage @@ -31,8 +34,10 @@ import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.WebRtcUtils 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 +import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID import org.session.libsession.utilities.ProfileKeyUtil import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences @@ -148,10 +153,27 @@ fun MessageReceiver.cancelTypingIndicatorsIfNeeded(senderPublicKey: String) { } private fun MessageReceiver.handleExpirationTimerUpdate(message: ExpirationTimerUpdate) { - if (message.duration!! > 0) { - SSKEnvironment.shared.messageExpirationManager.setExpirationTimer(message) - } else { - SSKEnvironment.shared.messageExpirationManager.disableExpirationTimer(message) + SSKEnvironment.shared.messageExpirationManager.insertExpirationTimerMessage(message) + + // TODO (Groups V2 - FIXME) + val isGroupV1 = message.groupPublicKey != null + + if (isNewConfigEnabled && !isGroupV1) return + + val module = MessagingModuleConfiguration.shared + try { + val threadId = fromSerialized(message.groupPublicKey?.let(::doubleEncodeGroupID) ?: message.sender!!) + .let(module.storage::getOrCreateThreadIdFor) + + module.storage.setExpirationConfiguration( + ExpirationConfiguration( + threadId, + message.expiryMode, + message.sentTimestamp!! + ) + ) + } catch (e: Exception) { + Log.e("Loki", "Failed to update expiration configuration.") } } @@ -192,10 +214,10 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { if (allClosedGroupPublicKeys.contains(closedGroup.publicKey)) { // just handle the closed group encryption key pairs to avoid sync'd devices getting out of sync storage.addClosedGroupEncryptionKeyPair(closedGroup.encryptionKeyPair!!, closedGroup.publicKey, message.sentTimestamp!!) - } else if (firstTimeSync) { + } else { // only handle new closed group if it's first time sync handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, closedGroup.publicKey, closedGroup.name, - closedGroup.encryptionKeyPair!!, closedGroup.members, closedGroup.admins, message.sentTimestamp!!, closedGroup.expirationTimer) + closedGroup.encryptionKeyPair!!, closedGroup.members, closedGroup.admins, message.sentTimestamp!!, -1) } } val allV2OpenGroups = storage.getAllOpenGroups().map { it.value.joinURL } @@ -233,8 +255,8 @@ fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): Long? { val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val timestamp = message.timestamp ?: return null val author = message.author ?: return null - val messageIdToDelete = storage.getMessageIdInDatabase(timestamp, author) ?: return null - messageDataProvider.getServerHashForMessage(messageIdToDelete)?.let { serverHash -> + val (messageIdToDelete, mms) = storage.getMessageIdInDatabase(timestamp, author) ?: return null + messageDataProvider.getServerHashForMessage(messageIdToDelete, mms)?.let { serverHash -> SnodeAPI.deleteMessage(author, listOf(serverHash)) } val deletedMessageId = messageDataProvider.updateMessageAsDeleted(timestamp, author) @@ -250,6 +272,14 @@ fun handleMessageRequestResponse(message: MessageRequestResponse) { } //endregion +private fun SignalServiceProtos.Content.ExpirationType.expiryMode(durationSeconds: Long) = takeIf { durationSeconds > 0 }?.let { + when (it) { + SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_READ -> ExpiryMode.AfterRead(durationSeconds) + SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_SEND, SignalServiceProtos.Content.ExpirationType.UNKNOWN -> ExpiryMode.AfterSend(durationSeconds) + else -> ExpiryMode.NONE + } +} ?: ExpiryMode.NONE + fun MessageReceiver.handleVisibleMessage( message: VisibleMessage, proto: SignalServiceProtos.Content, @@ -308,6 +338,17 @@ fun MessageReceiver.handleVisibleMessage( if (userPublicKey != messageSender && !isUserBlindedSender) { storage.setBlocksCommunityMessageRequests(recipient, message.blocksMessageRequests) } + + // update the disappearing / legacy banner for the sender + val disappearingState = when { + proto.dataMessage.expireTimer > 0 && !proto.hasExpirationType() -> Recipient.DisappearingState.LEGACY + else -> Recipient.DisappearingState.UPDATED + } + storage.updateDisappearingState( + messageSender, + threadID, + disappearingState + ) } // Parse quote if needed var quoteModel: QuoteModel? = null @@ -348,14 +389,7 @@ fun MessageReceiver.handleVisibleMessage( } } // Parse attachments if needed - val attachments = proto.dataMessage.attachmentsList.mapNotNull { attachmentProto -> - val attachment = Attachment.fromProto(attachmentProto) - if (!attachment.isValid()) { - return@mapNotNull null - } else { - return@mapNotNull attachment - } - } + val attachments = proto.dataMessage.attachmentsList.map(Attachment::fromProto).filter { it.isValid() } // Cancel any typing indicators if needed cancelTypingIndicatorsIfNeeded(message.sender!!) // Parse reaction if needed @@ -386,15 +420,12 @@ fun MessageReceiver.handleVisibleMessage( // Persist the message message.threadID = threadID - val messageID = - storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, - attachments, runThreadUpdate - ) ?: return null - val openGroupServerID = message.openGroupServerMessageID - if (openGroupServerID != null) { - val isSms = !(message.isMediaMessage() || attachments.isNotEmpty()) - storage.setOpenGroupServerMessageID(messageID, openGroupServerID, threadID, isSms) + val messageID = storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, attachments, runThreadUpdate) ?: return null + message.openGroupServerMessageID?.let { + val isSms = !message.isMediaMessage() && attachments.isEmpty() + storage.setOpenGroupServerMessageID(messageID, it, threadID, isSms) } + SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(message) return messageID } return null @@ -506,17 +537,20 @@ private fun ClosedGroupControlMessage.getPublicKey(): String = kind!!.let { when }} private fun MessageReceiver.handleNewClosedGroup(message: ClosedGroupControlMessage) { + val storage = MessagingModuleConfiguration.shared.storage val kind = message.kind!! as? ClosedGroupControlMessage.Kind.New ?: return val recipient = Recipient.from(MessagingModuleConfiguration.shared.context, Address.fromSerialized(message.sender!!), false) if (!recipient.isApproved && !recipient.isLocalNumber) return Log.e("Loki", "not accepting new closed group from unapproved recipient") val groupPublicKey = kind.publicKey.toByteArray().toHexString() + // hard code check by group public key in the big function because I can't be bothered to do group double decode re-encodej + if ((storage.getThreadIdFor(message.sender!!, groupPublicKey, null, false) ?: -1L) >= 0L) return val members = kind.members.map { it.toByteArray().toHexString() } val admins = kind.admins.map { it.toByteArray().toHexString() } - val expireTimer = kind.expirationTimer - handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, groupPublicKey, kind.name, kind.encryptionKeyPair!!, members, admins, message.sentTimestamp!!, expireTimer) + val expirationTimer = kind.expirationTimer + handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, groupPublicKey, kind.name, kind.encryptionKeyPair!!, members, admins, message.sentTimestamp!!, expirationTimer) } -private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPublicKey: String, name: String, encryptionKeyPair: ECKeyPair, members: List<String>, admins: List<String>, formationTimestamp: Long, expireTimer: Int) { +private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPublicKey: String, name: String, encryptionKeyPair: ECKeyPair, members: List<String>, admins: List<String>, formationTimestamp: Long, expirationTimer: Int) { val context = MessagingModuleConfiguration.shared.context val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey()!! @@ -548,7 +582,7 @@ private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPubli storage.updateTitle(groupID, name) storage.updateMembers(groupID, members.map { Address.fromSerialized(it) }) } else { - storage.createGroup(groupID, name, LinkedList(members.map { Address.fromSerialized(it) }), + storage.createGroup(groupID, name, LinkedList(members.map { fromSerialized(it) }), null, null, LinkedList(admins.map { Address.fromSerialized(it) }), formationTimestamp) } storage.setProfileSharing(Address.fromSerialized(groupID), true) @@ -556,9 +590,7 @@ private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPubli storage.addClosedGroupPublicKey(groupPublicKey) // Store the encryption key pair storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, sentTimestamp) - storage.createInitialConfigGroup(groupPublicKey, name, GroupUtil.createConfigMemberMap(members, admins), formationTimestamp, encryptionKeyPair) - // Set expiration timer - storage.setExpirationTimer(groupID, expireTimer) + storage.createInitialConfigGroup(groupPublicKey, name, GroupUtil.createConfigMemberMap(members, admins), formationTimestamp, encryptionKeyPair, expirationTimer) // Notify the PN server PushRegistryV1.register(device = MessagingModuleConfiguration.shared.device, publicKey = userPublicKey) // Notify the user diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index f0b20436fc..39ed79de1e 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -152,14 +152,18 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti Log.w("Loki", "shared config message handled in configs wasn't SharedConfigurationMessage but was ${message.javaClass.simpleName}") return@forEach } - forConfigObject.merge(hash!! to message.data) - latestMessageTimestamp = if ((message.sentTimestamp ?: 0L) > (latestMessageTimestamp ?: 0L)) { message.sentTimestamp } else { latestMessageTimestamp } + val merged = forConfigObject.merge(hash!! to message.data).firstOrNull { it == hash } + if (merged != null) { + // We successfully merged the hash, we can now update the timestamp + latestMessageTimestamp = if ((message.sentTimestamp ?: 0L) > (latestMessageTimestamp ?: 0L)) { message.sentTimestamp } else { latestMessageTimestamp } + } } catch (e: Exception) { Log.e("Loki", e) } } // process new results - if (forConfigObject.needsDump()) { + // latestMessageTimestamp should always be non-null if the config object needs dump + if (forConfigObject.needsDump() && latestMessageTimestamp != null) { configFactory.persist(forConfigObject, latestMessageTimestamp ?: SnodeAPI.nowWithOffset) } } @@ -210,7 +214,6 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti val responseList = (rawResponses["results"] as List<RawResponse>) // in case we had null configs, the array won't be fully populated // index of the sparse array key iterator should be the request index, with the key being the namespace - // TODO: add in specific ordering of config namespaces for processing listOfNotNull( configFactory.user?.configNamespace(), configFactory.contacts?.configNamespace(), diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt index 6cf18ba5c5..82fb071e62 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt @@ -1,6 +1,7 @@ package org.session.libsession.messaging.utilities import android.content.Context +import android.util.Log import org.session.libsession.R import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.calls.CallMessageType @@ -9,14 +10,17 @@ import org.session.libsession.messaging.calls.CallMessageType.CALL_INCOMING import org.session.libsession.messaging.calls.CallMessageType.CALL_MISSED import org.session.libsession.messaging.calls.CallMessageType.CALL_OUTGOING import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.messages.ExpirationConfiguration.Companion.isNewConfigEnabled import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage import org.session.libsession.utilities.ExpirationUtil +import org.session.libsession.utilities.getExpirationTypeDisplayValue +import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.truncateIdForDisplay object UpdateMessageBuilder { val storage = MessagingModuleConfiguration.shared.storage - fun getSenderName(senderId: String) = storage.getContactWithSessionID(senderId) + private fun getSenderName(senderId: String) = storage.getContactWithSessionID(senderId) ?.displayName(Contact.ContactContext.REGULAR) ?: truncateIdForDisplay(senderId) @@ -73,18 +77,44 @@ object UpdateMessageBuilder { } } - fun buildExpirationTimerMessage(context: Context, duration: Long, senderId: String? = null, isOutgoing: Boolean = false): String { + fun buildExpirationTimerMessage( + context: Context, + duration: Long, + recipient: Recipient, + senderId: String? = null, + isOutgoing: Boolean = false, + timestamp: Long, + expireStarted: Long + ): String { if (!isOutgoing && senderId == null) return "" - val senderName: String = if (!isOutgoing) { - getSenderName(senderId!!) - } else { context.getString(R.string.MessageRecord_you) } + val senderName = if (isOutgoing) context.getString(R.string.MessageRecord_you) else getSenderName(senderId!!) return if (duration <= 0) { - if (isOutgoing) context.getString(R.string.MessageRecord_you_disabled_disappearing_messages) - else context.getString(R.string.MessageRecord_s_disabled_disappearing_messages, senderName) + if (isOutgoing) { + if (!isNewConfigEnabled) context.getString(R.string.MessageRecord_you_disabled_disappearing_messages) + else context.getString(if (recipient.is1on1) R.string.MessageRecord_you_turned_off_disappearing_messages_1_on_1 else R.string.MessageRecord_you_turned_off_disappearing_messages) + } else { + if (!isNewConfigEnabled) context.getString(R.string.MessageRecord_s_disabled_disappearing_messages, senderName) + else context.getString(if (recipient.is1on1) R.string.MessageRecord_s_turned_off_disappearing_messages_1_on_1 else R.string.MessageRecord_s_turned_off_disappearing_messages, senderName) + } } else { val time = ExpirationUtil.getExpirationDisplayValue(context, duration.toInt()) - if (isOutgoing)context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time) - else context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, senderName, time) + val action = context.getExpirationTypeDisplayValue(timestamp == expireStarted) + if (isOutgoing) { + if (!isNewConfigEnabled) context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time) + else context.getString( + if (recipient.is1on1) R.string.MessageRecord_you_set_messages_to_disappear_s_after_s_1_on_1 else R.string.MessageRecord_you_set_messages_to_disappear_s_after_s, + time, + action + ) + } else { + if (!isNewConfigEnabled) context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, senderName, time) + else context.getString( + if (recipient.is1on1) R.string.MessageRecord_s_set_messages_to_disappear_s_after_s_1_on_1 else R.string.MessageRecord_s_set_messages_to_disappear_s_after_s, + senderName, + time, + action + ) + } } } diff --git a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt index 497f444dac..04b0f722c1 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -611,7 +611,7 @@ object OnionRequestAPI { } if (body["t"] != null) { val timestamp = body["t"] as Long - val offset = timestamp - Date().time + val offset = timestamp - System.currentTimeMillis() SnodeAPI.clockOffset = offset } if (body.containsKey("hf")) { diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt index b1a274773c..4064aba62d 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -533,14 +533,10 @@ object SnodeAPI { fun getExpiries(messageHashes: List<String>, publicKey: String) : RawResponsePromise { val userEd25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return Promise.ofFail(NullPointerException("No user key pair")) + val hashes = messageHashes.takeIf { it.size != 1 } ?: (messageHashes + "///////////////////////////////////////////") // TODO remove this when bug is fixed on nodes. return retryIfNeeded(maxRetryCount) { val timestamp = System.currentTimeMillis() + clockOffset - val params = mutableMapOf( - "pubkey" to publicKey, - "messages" to messageHashes, - "timestamp" to timestamp - ) - val signData = "${Snode.Method.GetExpiries.rawValue}$timestamp${messageHashes.joinToString(separator = "")}".toByteArray() + val signData = "${Snode.Method.GetExpiries.rawValue}$timestamp${hashes.joinToString(separator = "")}".toByteArray() val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString val signature = ByteArray(Sign.BYTES) @@ -555,9 +551,14 @@ object SnodeAPI { Log.e("Loki", "Signing data failed with user secret key", e) return@retryIfNeeded Promise.ofFail(e) } - params["pubkey_ed25519"] = ed25519PublicKey - params["signature"] = Base64.encodeBytes(signature) - getSingleTargetSnode(publicKey).bind { snode -> + val params = mapOf( + "pubkey" to publicKey, + "messages" to hashes, + "timestamp" to timestamp, + "pubkey_ed25519" to ed25519PublicKey, + "signature" to Base64.encodeBytes(signature) + ) + getSingleTargetSnode(publicKey) bind { snode -> invoke(Snode.Method.GetExpiries, snode, params, publicKey) } } @@ -761,6 +762,62 @@ object SnodeAPI { } } + fun updateExpiry(updatedExpiryMs: Long, serverHashes: List<String>): Promise<Map<String, Pair<List<String>, Long>>, Exception> { + return retryIfNeeded(maxRetryCount) { + val module = MessagingModuleConfiguration.shared + val userED25519KeyPair = module.getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) + val userPublicKey = module.storage.getUserPublicKey() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) + val updatedExpiryMsWithNetworkOffset = updatedExpiryMs + clockOffset + getSingleTargetSnode(userPublicKey).bind { snode -> + retryIfNeeded(maxRetryCount) { + // "expire" || expiry || messages[0] || ... || messages[N] + val verificationData = + (Snode.Method.Expire.rawValue + updatedExpiryMsWithNetworkOffset + serverHashes.fold("") { a, v -> a + v }).toByteArray() + val signature = ByteArray(Sign.BYTES) + sodium.cryptoSignDetached( + signature, + verificationData, + verificationData.size.toLong(), + userED25519KeyPair.secretKey.asBytes + ) + val params = mapOf( + "pubkey" to userPublicKey, + "pubkey_ed25519" to userED25519KeyPair.publicKey.asHexString, + "expiry" to updatedExpiryMs, + "messages" to serverHashes, + "signature" to Base64.encodeBytes(signature) + ) + invoke(Snode.Method.Expire, snode, params, userPublicKey).map { rawResponse -> + val swarms = rawResponse["swarm"] as? Map<String, Any> ?: return@map mapOf() + val result = swarms.mapNotNull { (hexSnodePublicKey, rawJSON) -> + val json = rawJSON as? Map<String, Any> ?: return@mapNotNull null + val isFailed = json["failed"] as? Boolean ?: false + val statusCode = json["code"] as? String + val reason = json["reason"] as? String + hexSnodePublicKey to if (isFailed) { + Log.e("Loki", "Failed to update expiry for: $hexSnodePublicKey due to error: $reason ($statusCode).") + listOf<String>() to 0L + } else { + val hashes = json["updated"] as List<String> + val expiryApplied = json["expiry"] as Long + val signature = json["signature"] as String + val snodePublicKey = Key.fromHexString(hexSnodePublicKey) + // The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) + val message = (userPublicKey + serverHashes.fold("") { a, v -> a + v } + hashes.fold("") { a, v -> a + v }).toByteArray() + if (sodium.cryptoSignVerifyDetached(Base64.decode(signature), message, message.size, snodePublicKey.asBytes)) { + hashes to expiryApplied + } else listOf<String>() to 0L + } + } + return@map result.toMap() + }.fail { e -> + Log.e("Loki", "Failed to update expiry", e) + } + } + } + } + } + fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0, updateLatestHash: Boolean = true, updateStoredHashes: Boolean = true): List<Pair<SignalServiceProtos.Envelope, String?>> { val messages = rawResponse["messages"] as? List<*> return if (messages != null) { diff --git a/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt b/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt index a45fcd6761..8add1da849 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt @@ -19,5 +19,5 @@ interface ConfigFactoryProtocol { } interface ConfigFactoryUpdateListener { - fun notifyUpdates(forConfigObject: ConfigBase) + fun notifyUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.java b/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.java deleted file mode 100644 index 88130f3f95..0000000000 --- a/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.session.libsession.utilities; - -import android.content.Context; - -import java.util.concurrent.TimeUnit; - -import org.session.libsession.R; - -public class ExpirationUtil { - - public static String getExpirationDisplayValue(Context context, int expirationTime) { - if (expirationTime <= 0) { - return context.getString(R.string.expiration_off); - } else if (expirationTime < TimeUnit.MINUTES.toSeconds(1)) { - return context.getResources().getQuantityString(R.plurals.expiration_seconds, expirationTime, expirationTime); - } else if (expirationTime < TimeUnit.HOURS.toSeconds(1)) { - int minutes = expirationTime / (int)TimeUnit.MINUTES.toSeconds(1); - return context.getResources().getQuantityString(R.plurals.expiration_minutes, minutes, minutes); - } else if (expirationTime < TimeUnit.DAYS.toSeconds(1)) { - int hours = expirationTime / (int)TimeUnit.HOURS.toSeconds(1); - return context.getResources().getQuantityString(R.plurals.expiration_hours, hours, hours); - } else if (expirationTime < TimeUnit.DAYS.toSeconds(7)) { - int days = expirationTime / (int)TimeUnit.DAYS.toSeconds(1); - return context.getResources().getQuantityString(R.plurals.expiration_days, days, days); - } else { - int weeks = expirationTime / (int)TimeUnit.DAYS.toSeconds(7); - return context.getResources().getQuantityString(R.plurals.expiration_weeks, weeks, weeks); - } - } - - public static String getExpirationAbbreviatedDisplayValue(Context context, int expirationTime) { - if (expirationTime < TimeUnit.MINUTES.toSeconds(1)) { - return context.getResources().getString(R.string.expiration_seconds_abbreviated, expirationTime); - } else if (expirationTime < TimeUnit.HOURS.toSeconds(1)) { - int minutes = expirationTime / (int)TimeUnit.MINUTES.toSeconds(1); - return context.getResources().getString(R.string.expiration_minutes_abbreviated, minutes); - } else if (expirationTime < TimeUnit.DAYS.toSeconds(1)) { - int hours = expirationTime / (int)TimeUnit.HOURS.toSeconds(1); - return context.getResources().getString(R.string.expiration_hours_abbreviated, hours); - } else if (expirationTime < TimeUnit.DAYS.toSeconds(7)) { - int days = expirationTime / (int)TimeUnit.DAYS.toSeconds(1); - return context.getResources().getString(R.string.expiration_days_abbreviated, days); - } else { - int weeks = expirationTime / (int)TimeUnit.DAYS.toSeconds(7); - return context.getResources().getString(R.string.expiration_weeks_abbreviated, weeks); - } - } - - -} diff --git a/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.kt b/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.kt new file mode 100644 index 0000000000..f3647fa4f8 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.kt @@ -0,0 +1,57 @@ +package org.session.libsession.utilities + +import android.content.Context +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.R +import java.util.concurrent.TimeUnit +import kotlin.time.Duration + +fun Context.getExpirationTypeDisplayValue(sent: Boolean) = if (sent) getString(R.string.MessageRecord_state_sent) else getString(R.string.MessageRecord_state_read) + +object ExpirationUtil { + @JvmStatic + fun getExpirationDisplayValue(context: Context, duration: Duration): String = getExpirationDisplayValue(context, duration.inWholeSeconds.toInt()) + + @JvmStatic + fun getExpirationDisplayValue(context: Context, expirationTime: Int): String { + return if (expirationTime <= 0) { + context.getString(R.string.expiration_off) + } else if (expirationTime < TimeUnit.MINUTES.toSeconds(1)) { + context.resources.getQuantityString( + R.plurals.expiration_seconds, + expirationTime, + expirationTime + ) + } else if (expirationTime < TimeUnit.HOURS.toSeconds(1)) { + val minutes = expirationTime / TimeUnit.MINUTES.toSeconds(1).toInt() + context.resources.getQuantityString(R.plurals.expiration_minutes, minutes, minutes) + } else if (expirationTime < TimeUnit.DAYS.toSeconds(1)) { + val hours = expirationTime / TimeUnit.HOURS.toSeconds(1).toInt() + context.resources.getQuantityString(R.plurals.expiration_hours, hours, hours) + } else if (expirationTime < TimeUnit.DAYS.toSeconds(7)) { + val days = expirationTime / TimeUnit.DAYS.toSeconds(1).toInt() + context.resources.getQuantityString(R.plurals.expiration_days, days, days) + } else { + val weeks = expirationTime / TimeUnit.DAYS.toSeconds(7).toInt() + context.resources.getQuantityString(R.plurals.expiration_weeks, weeks, weeks) + } + } + + fun getExpirationAbbreviatedDisplayValue(context: Context, expirationTime: Long): String { + return if (expirationTime < TimeUnit.MINUTES.toSeconds(1)) { + context.resources.getString(R.string.expiration_seconds_abbreviated, expirationTime) + } else if (expirationTime < TimeUnit.HOURS.toSeconds(1)) { + val minutes = expirationTime / TimeUnit.MINUTES.toSeconds(1) + context.resources.getString(R.string.expiration_minutes_abbreviated, minutes) + } else if (expirationTime < TimeUnit.DAYS.toSeconds(1)) { + val hours = expirationTime / TimeUnit.HOURS.toSeconds(1) + context.resources.getString(R.string.expiration_hours_abbreviated, hours) + } else if (expirationTime < TimeUnit.DAYS.toSeconds(7)) { + val days = expirationTime / TimeUnit.DAYS.toSeconds(1) + context.resources.getString(R.string.expiration_days_abbreviated, days) + } else { + val weeks = expirationTime / TimeUnit.DAYS.toSeconds(7) + context.resources.getString(R.string.expiration_weeks_abbreviated, weeks) + } + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/GroupRecord.kt b/libsession/src/main/java/org/session/libsession/utilities/GroupRecord.kt index 630e6a89ef..a8cf6fd796 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/GroupRecord.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/GroupRecord.kt @@ -34,4 +34,4 @@ class GroupRecord( this.admins = Address.fromSerializedList(admins!!, ',') } } -} \ No newline at end of file +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt b/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt index 5d6741d30e..630db9e991 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt @@ -108,22 +108,23 @@ object GroupUtil { @JvmStatic @Throws(IOException::class) - fun doubleDecodeGroupId(groupID: String): String { - return Hex.toStringCondensed(getDecodedGroupIDAsData(getDecodedGroupID(groupID))) - } + fun doubleDecodeGroupId(groupID: String): String = + Hex.toStringCondensed(getDecodedGroupIDAsData(getDecodedGroupID(groupID))) + + @JvmStatic + fun addressToGroupSessionId(address: Address): String = + doubleDecodeGroupId(address.toGroupString()) fun createConfigMemberMap( members: Collection<String>, admins: Collection<String> ): Map<String, Boolean> { // Start with admins - val memberMap = admins.associate { - it to true - }.toMutableMap() + val memberMap = admins.associateWith { true }.toMutableMap() // Add the remaining members (there may be duplicates, so only add ones that aren't already in there from admins) for (member in members) { - if (!memberMap.contains(member)) { + if (member !in memberMap) { memberMap[member] = false } } diff --git a/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt b/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt index f647cc0f48..5915d5c4ee 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt @@ -1,9 +1,15 @@ package org.session.libsession.utilities import android.content.Context +import android.util.Log +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier +import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.utilities.recipients.Recipient class SSKEnvironment( @@ -37,9 +43,37 @@ class SSKEnvironment( } interface MessageExpirationManagerProtocol { - fun setExpirationTimer(message: ExpirationTimerUpdate) - fun disableExpirationTimer(message: ExpirationTimerUpdate) - fun startAnyExpiration(timestamp: Long, author: String) + fun insertExpirationTimerMessage(message: ExpirationTimerUpdate) + fun startAnyExpiration(timestamp: Long, author: String, expireStartedAt: Long) + + fun maybeStartExpiration(message: Message, startDisappearAfterRead: Boolean = false) { + if (message is ExpirationTimerUpdate && message.isGroup || message is ClosedGroupControlMessage) return + + maybeStartExpiration( + message.sentTimestamp ?: return, + message.sender ?: return, + message.expiryMode, + startDisappearAfterRead || message.isSenderSelf + ) + } + + fun startDisappearAfterRead(timestamp: Long, sender: String) { + startAnyExpiration( + timestamp, + sender, + expireStartedAt = nowWithOffset.coerceAtLeast(timestamp + 1) + ) + } + + fun maybeStartExpiration(timestamp: Long, sender: String, mode: ExpiryMode, startDisappearAfterRead: Boolean = false) { + val expireStartedAt = when (mode) { + is ExpiryMode.AfterSend -> timestamp + is ExpiryMode.AfterRead -> if (startDisappearAfterRead) nowWithOffset.coerceAtLeast(timestamp + 1) else return + else -> return + } + + startAnyExpiration(timestamp, sender, expireStartedAt) + } } companion object { diff --git a/libsession/src/main/java/org/session/libsession/utilities/Util.kt b/libsession/src/main/java/org/session/libsession/utilities/Util.kt index 80a8d3282d..17009caa7d 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Util.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/Util.kt @@ -365,4 +365,21 @@ object Util { val digitGroups = (Math.log10(sizeBytes.toDouble()) / Math.log10(1024.0)).toInt() return DecimalFormat("#,##0.#").format(sizeBytes / Math.pow(1024.0, digitGroups.toDouble())) + " " + units[digitGroups] } -} \ No newline at end of file +} + +fun <T, R> T.runIf(condition: Boolean, block: T.() -> R): R where T: R = if (condition) block() else this + +fun <T, K: Any> Iterable<T>.associateByNotNull( + keySelector: (T) -> K? +) = associateByNotNull(keySelector) { it } + +fun <T, K: Any, V: Any> Iterable<T>.associateByNotNull( + keySelector: (T) -> K?, + valueTransform: (T) -> V?, +): Map<K, V> = buildMap { + for (item in this@associateByNotNull) { + val key = keySelector(item) ?: continue + val value = valueTransform(item) ?: continue + this[key] = value + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/ViewUtils.kt b/libsession/src/main/java/org/session/libsession/utilities/ViewUtils.kt index 80c0293238..7be7224f3a 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/ViewUtils.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/ViewUtils.kt @@ -2,6 +2,8 @@ package org.session.libsession.utilities import android.content.Context import android.util.TypedValue +import android.view.View +import android.view.ViewGroup import androidx.annotation.AttrRes import androidx.annotation.ColorInt @@ -14,3 +16,7 @@ fun Context.getColorFromAttr( theme.resolveAttribute(attrColor, typedValue, resolveRefs) return typedValue.data } + +inline fun <reified LP: ViewGroup.LayoutParams> View.modifyLayoutParams(function: LP.() -> Unit) { + layoutParams = (layoutParams as LP).apply { function() } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java b/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java index 20f285b59c..720806ef89 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java +++ b/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java @@ -86,6 +86,7 @@ public class Recipient implements RecipientModifiedListener { private boolean blocked = false; private boolean approved = false; private boolean approvedMe = false; + private DisappearingState disappearingState = null; private VibrateState messageVibrate = VibrateState.DEFAULT; private VibrateState callVibrate = VibrateState.DEFAULT; private int expireMessages = 0; @@ -163,6 +164,7 @@ public class Recipient implements RecipientModifiedListener { this.unidentifiedAccessMode = stale.unidentifiedAccessMode; this.forceSmsSelection = stale.forceSmsSelection; this.notifyType = stale.notifyType; + this.disappearingState = stale.disappearingState; this.participants.clear(); this.participants.addAll(stale.participants); @@ -194,6 +196,7 @@ public class Recipient implements RecipientModifiedListener { this.forceSmsSelection = details.get().forceSmsSelection; this.notifyType = details.get().notifyType; this.blocksCommunityMessageRequests = details.get().blocksCommunityMessageRequests; + this.disappearingState = details.get().disappearingState; this.participants.clear(); this.participants.addAll(details.get().participants); @@ -230,6 +233,7 @@ public class Recipient implements RecipientModifiedListener { Recipient.this.unidentifiedAccessMode = result.unidentifiedAccessMode; Recipient.this.forceSmsSelection = result.forceSmsSelection; Recipient.this.notifyType = result.notifyType; + Recipient.this.disappearingState = result.disappearingState; Recipient.this.blocksCommunityMessageRequests = result.blocksCommunityMessageRequests; Recipient.this.participants.clear(); @@ -453,6 +457,7 @@ public class Recipient implements RecipientModifiedListener { public boolean isContactRecipient() { return address.isContact(); } + public boolean is1on1() { return address.isContact() && !isLocalNumber; } public boolean isOpenGroupRecipient() { return address.isOpenGroup(); @@ -681,6 +686,18 @@ public class Recipient implements RecipientModifiedListener { notifyListeners(); } + public synchronized DisappearingState getDisappearingState() { + return disappearingState; + } + + public void setDisappearingState(DisappearingState disappearingState) { + synchronized (this) { + this.disappearingState = disappearingState; + } + + notifyListeners(); + } + public synchronized RegisteredState getRegistered() { if (isPushGroupRecipient()) return RegisteredState.REGISTERED; @@ -770,6 +787,10 @@ public class Recipient implements RecipientModifiedListener { return this; } + public synchronized boolean showCallMenu() { + return !isGroupRecipient() && hasApprovedMe(); + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -854,6 +875,24 @@ public class Recipient implements RecipientModifiedListener { } } + public enum DisappearingState { + LEGACY(0), UPDATED(1); + + private final int id; + + DisappearingState(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public static DisappearingState fromId(int id) { + return values()[id]; + } + } + public enum RegisteredState { UNKNOWN(0), REGISTERED(1), NOT_REGISTERED(2); @@ -896,6 +935,7 @@ public class Recipient implements RecipientModifiedListener { private final boolean approvedMe; private final long muteUntil; private final int notifyType; + private final DisappearingState disappearingState; private final VibrateState messageVibrateState; private final VibrateState callVibrateState; private final Uri messageRingtone; @@ -920,7 +960,8 @@ public class Recipient implements RecipientModifiedListener { public RecipientSettings(boolean blocked, boolean approved, boolean approvedMe, long muteUntil, int notifyType, - @NonNull VibrateState messageVibrateState, + @NonNull DisappearingState disappearingState, + @NonNull VibrateState messageVibrateState, @NonNull VibrateState callVibrateState, @Nullable Uri messageRingtone, @Nullable Uri callRingtone, @@ -948,6 +989,7 @@ public class Recipient implements RecipientModifiedListener { this.approvedMe = approvedMe; this.muteUntil = muteUntil; this.notifyType = notifyType; + this.disappearingState = disappearingState; this.messageVibrateState = messageVibrateState; this.callVibrateState = callVibrateState; this.messageRingtone = messageRingtone; @@ -995,6 +1037,10 @@ public class Recipient implements RecipientModifiedListener { return notifyType; } + public @NonNull DisappearingState getDisappearingState() { + return disappearingState; + } + public @NonNull VibrateState getMessageVibrateState() { return messageVibrateState; } diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java b/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java index 195250cb37..374956072b 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java +++ b/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java @@ -31,6 +31,7 @@ import org.session.libsession.utilities.ListenableFutureTask; import org.session.libsession.utilities.MaterialColor; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; +import org.session.libsession.utilities.recipients.Recipient.DisappearingState; import org.session.libsession.utilities.recipients.Recipient.RecipientSettings; import org.session.libsession.utilities.recipients.Recipient.RegisteredState; import org.session.libsession.utilities.recipients.Recipient.UnidentifiedAccessMode; @@ -159,6 +160,7 @@ class RecipientProvider { @Nullable final Uri callRingtone; final long mutedUntil; final int notifyType; + @Nullable final DisappearingState disappearingState; @Nullable final VibrateState messageVibrateState; @Nullable final VibrateState callVibrateState; final boolean blocked; @@ -193,6 +195,7 @@ class RecipientProvider { this.callRingtone = settings != null ? settings.getCallRingtone() : null; this.mutedUntil = settings != null ? settings.getMuteUntil() : 0; this.notifyType = settings != null ? settings.getNotifyType() : 0; + this.disappearingState = settings != null ? settings.getDisappearingState() : null; this.messageVibrateState = settings != null ? settings.getMessageVibrateState() : null; this.callVibrateState = settings != null ? settings.getCallVibrateState() : null; this.blocked = settings != null && settings.isBlocked(); diff --git a/libsession/src/main/res/values/attrs.xml b/libsession/src/main/res/values/attrs.xml index 64fab0950e..474fe565e0 100644 --- a/libsession/src/main/res/values/attrs.xml +++ b/libsession/src/main/res/values/attrs.xml @@ -252,11 +252,6 @@ <attr name="doc_downloadButtonTint" format="color" /> </declare-styleable> - <declare-styleable name="ConversationItemFooter"> - <attr name="footer_text_color" format="color" /> - <attr name="footer_icon_color" format="color" /> - </declare-styleable> - <declare-styleable name="ConversationItemThumbnail"> <attr name="conversationThumbnail_minWidth" format="dimension" /> <attr name="conversationThumbnail_maxWidth" format="dimension" /> diff --git a/libsession/src/main/res/values/strings.xml b/libsession/src/main/res/values/strings.xml index c9904920f2..1527c32d81 100644 --- a/libsession/src/main/res/values/strings.xml +++ b/libsession/src/main/res/values/strings.xml @@ -15,12 +15,26 @@ <string name="MessageRecord_s_called_you">%s called you</string> <string name="MessageRecord_called_s">Called %s</string> <string name="MessageRecord_missed_call_from">Missed call from %s</string> + <string name="MessageRecord_follow_setting">Follow Setting</string> + <string name="AccessibilityId_follow_setting">Follow setting</string> <string name="MessageRecord_you_disabled_disappearing_messages">You disabled disappearing messages.</string> + <string name="MessageRecord_you_turned_off_disappearing_messages">You have turned off disappearing messages.</string> + <string name="MessageRecord_you_turned_off_disappearing_messages_1_on_1">You turned off disappearing messages. Messages you send will no longer disappear.</string> <string name="MessageRecord_s_disabled_disappearing_messages">%1$s disabled disappearing messages.</string> + <string name="MessageRecord_s_turned_off_disappearing_messages">%1$s turned off disappearing messages.</string> + <string name="MessageRecord_s_turned_off_disappearing_messages_1_on_1">%1$s has turned off disappearing messages. Messages they send will no longer disappear.</string> <string name="MessageRecord_you_set_disappearing_message_time_to_s">You set the disappearing message timer to %1$s</string> + <string name="MessageRecord_you_set_messages_to_disappear_s_after_s">You have set messages to disappear %1$s after they have been %2$s</string> + <string name="MessageRecord_you_set_messages_to_disappear_s_after_s_1_on_1">You set your messages to disappear %1$s after they have been %2$s.</string> + <string name="MessageRecord_you_changed_messages_to_disappear_s_after_s">You have changed messages to disappear %1$s after they have been %2$s</string> <string name="MessageRecord_s_set_disappearing_message_time_to_s">%1$s set the disappearing message timer to %2$s</string> + <string name="MessageRecord_s_set_messages_to_disappear_s_after_s">%1$s has set messages to disappear %2$s after they have been %3$s</string> + <string name="MessageRecord_s_set_messages_to_disappear_s_after_s_1_on_1">%1$s has set their messages to disappear %2$s after they have been %3$s.</string> + <string name="MessageRecord_s_changed_messages_to_disappear_s_after_s">%1$s has changed messages to disappear %2$s after they have been %3$s</string> <string name="MessageRecord_s_took_a_screenshot">%1$s took a screenshot.</string> <string name="MessageRecord_media_saved_by_s">Media saved by %1$s.</string> + <string name="MessageRecord_state_read">read</string> + <string name="MessageRecord_state_sent">sent</string> <!-- expiration --> <string name="expiration_off">Off</string> diff --git a/libsignal/build.gradle b/libsignal/build.gradle index 1ea5f2de04..5ac0d0b4fc 100644 --- a/libsignal/build.gradle +++ b/libsignal/build.gradle @@ -25,6 +25,6 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" testImplementation "junit:junit:$junitVersion" - testImplementation "org.assertj:assertj-core:1.7.1" + testImplementation "org.assertj:assertj-core:3.11.1" testImplementation "org.conscrypt:conscrypt-openjdk-uber:2.0.0" } diff --git a/libsignal/protobuf/SignalService.proto b/libsignal/protobuf/SignalService.proto index a2448604f3..6e13be72ef 100644 --- a/libsignal/protobuf/SignalService.proto +++ b/libsignal/protobuf/SignalService.proto @@ -43,6 +43,12 @@ message UnsendRequest { } message Content { + enum ExpirationType { + UNKNOWN = 0; + DELETE_AFTER_READ = 1; + DELETE_AFTER_SEND = 2; + } + optional DataMessage dataMessage = 1; optional CallMessage callMessage = 3; optional ReceiptMessage receiptMessage = 5; @@ -52,6 +58,9 @@ message Content { optional UnsendRequest unsendRequest = 9; optional MessageRequestResponse messageRequestResponse = 10; optional SharedConfigMessage sharedConfigMessage = 11; + optional ExpirationType expirationType = 12; + optional uint32 expirationTimer = 13; + optional uint64 lastDisappearingMessageChangeTimestamp = 14; } message KeyPair { diff --git a/libsignal/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt b/libsignal/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt index 18880f5538..37c00a037d 100644 --- a/libsignal/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt +++ b/libsignal/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt @@ -38,4 +38,7 @@ interface LokiAPIDatabaseProtocol { fun getForkInfo(): ForkInfo fun setForkInfo(forkInfo: ForkInfo) fun migrateLegacyOpenGroup(legacyServerId: String, newServerId: String) + fun getLastLegacySenderAddress(threadRecipientAddress: String): String? + fun setLastLegacySenderAddress(threadRecipientAddress: String, senderRecipientAddress: String?) + } diff --git a/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java b/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java index 895575b09c..b53af78ea3 100644 --- a/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java +++ b/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java @@ -2482,6 +2482,36 @@ public final class SignalServiceProtos { * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> */ org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder getSharedConfigMessageOrBuilder(); + + // optional .signalservice.Content.ExpirationType expirationType = 12; + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + boolean hasExpirationType(); + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType getExpirationType(); + + // optional uint32 expirationTimer = 13; + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + boolean hasExpirationTimer(); + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + int getExpirationTimer(); + + // optional uint64 lastDisappearingMessageChangeTimestamp = 14; + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + boolean hasLastDisappearingMessageChangeTimestamp(); + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + long getLastDisappearingMessageChangeTimestamp(); } /** * Protobuf type {@code signalservice.Content} @@ -2651,6 +2681,27 @@ public final class SignalServiceProtos { bitField0_ |= 0x00000100; break; } + case 96: { + int rawValue = input.readEnum(); + org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType value = org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType.valueOf(rawValue); + if (value == null) { + unknownFields.mergeVarintField(12, rawValue); + } else { + bitField0_ |= 0x00000200; + expirationType_ = value; + } + break; + } + case 104: { + bitField0_ |= 0x00000400; + expirationTimer_ = input.readUInt32(); + break; + } + case 112: { + bitField0_ |= 0x00000800; + lastDisappearingMessageChangeTimestamp_ = input.readUInt64(); + break; + } } } } catch (com.google.protobuf.InvalidProtocolBufferException e) { @@ -2690,6 +2741,97 @@ public final class SignalServiceProtos { return PARSER; } + /** + * Protobuf enum {@code signalservice.Content.ExpirationType} + */ + public enum ExpirationType + implements com.google.protobuf.ProtocolMessageEnum { + /** + * <code>UNKNOWN = 0;</code> + */ + UNKNOWN(0, 0), + /** + * <code>DELETE_AFTER_READ = 1;</code> + */ + DELETE_AFTER_READ(1, 1), + /** + * <code>DELETE_AFTER_SEND = 2;</code> + */ + DELETE_AFTER_SEND(2, 2), + ; + + /** + * <code>UNKNOWN = 0;</code> + */ + public static final int UNKNOWN_VALUE = 0; + /** + * <code>DELETE_AFTER_READ = 1;</code> + */ + public static final int DELETE_AFTER_READ_VALUE = 1; + /** + * <code>DELETE_AFTER_SEND = 2;</code> + */ + public static final int DELETE_AFTER_SEND_VALUE = 2; + + + public final int getNumber() { return value; } + + public static ExpirationType valueOf(int value) { + switch (value) { + case 0: return UNKNOWN; + case 1: return DELETE_AFTER_READ; + case 2: return DELETE_AFTER_SEND; + default: return null; + } + } + + public static com.google.protobuf.Internal.EnumLiteMap<ExpirationType> + internalGetValueMap() { + return internalValueMap; + } + private static com.google.protobuf.Internal.EnumLiteMap<ExpirationType> + internalValueMap = + new com.google.protobuf.Internal.EnumLiteMap<ExpirationType>() { + public ExpirationType findValueByNumber(int number) { + return ExpirationType.valueOf(number); + } + }; + + public final com.google.protobuf.Descriptors.EnumValueDescriptor + getValueDescriptor() { + return getDescriptor().getValues().get(index); + } + public final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptorForType() { + return getDescriptor(); + } + public static final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptor() { + return org.session.libsignal.protos.SignalServiceProtos.Content.getDescriptor().getEnumTypes().get(0); + } + + private static final ExpirationType[] VALUES = values(); + + public static ExpirationType valueOf( + com.google.protobuf.Descriptors.EnumValueDescriptor desc) { + if (desc.getType() != getDescriptor()) { + throw new java.lang.IllegalArgumentException( + "EnumValueDescriptor is not for this type."); + } + return VALUES[desc.getIndex()]; + } + + private final int index; + private final int value; + + private ExpirationType(int index, int value) { + this.index = index; + this.value = value; + } + + // @@protoc_insertion_point(enum_scope:signalservice.Content.ExpirationType) + } + private int bitField0_; // optional .signalservice.DataMessage dataMessage = 1; public static final int DATAMESSAGE_FIELD_NUMBER = 1; @@ -2889,6 +3031,54 @@ public final class SignalServiceProtos { return sharedConfigMessage_; } + // optional .signalservice.Content.ExpirationType expirationType = 12; + public static final int EXPIRATIONTYPE_FIELD_NUMBER = 12; + private org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType expirationType_; + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + public boolean hasExpirationType() { + return ((bitField0_ & 0x00000200) == 0x00000200); + } + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + public org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType getExpirationType() { + return expirationType_; + } + + // optional uint32 expirationTimer = 13; + public static final int EXPIRATIONTIMER_FIELD_NUMBER = 13; + private int expirationTimer_; + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + public boolean hasExpirationTimer() { + return ((bitField0_ & 0x00000400) == 0x00000400); + } + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + public int getExpirationTimer() { + return expirationTimer_; + } + + // optional uint64 lastDisappearingMessageChangeTimestamp = 14; + public static final int LASTDISAPPEARINGMESSAGECHANGETIMESTAMP_FIELD_NUMBER = 14; + private long lastDisappearingMessageChangeTimestamp_; + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + public boolean hasLastDisappearingMessageChangeTimestamp() { + return ((bitField0_ & 0x00000800) == 0x00000800); + } + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + public long getLastDisappearingMessageChangeTimestamp() { + return lastDisappearingMessageChangeTimestamp_; + } + private void initFields() { dataMessage_ = org.session.libsignal.protos.SignalServiceProtos.DataMessage.getDefaultInstance(); callMessage_ = org.session.libsignal.protos.SignalServiceProtos.CallMessage.getDefaultInstance(); @@ -2899,6 +3089,9 @@ public final class SignalServiceProtos { unsendRequest_ = org.session.libsignal.protos.SignalServiceProtos.UnsendRequest.getDefaultInstance(); messageRequestResponse_ = org.session.libsignal.protos.SignalServiceProtos.MessageRequestResponse.getDefaultInstance(); sharedConfigMessage_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); + expirationType_ = org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType.UNKNOWN; + expirationTimer_ = 0; + lastDisappearingMessageChangeTimestamp_ = 0L; } private byte memoizedIsInitialized = -1; public final boolean isInitialized() { @@ -2993,6 +3186,15 @@ public final class SignalServiceProtos { if (((bitField0_ & 0x00000100) == 0x00000100)) { output.writeMessage(11, sharedConfigMessage_); } + if (((bitField0_ & 0x00000200) == 0x00000200)) { + output.writeEnum(12, expirationType_.getNumber()); + } + if (((bitField0_ & 0x00000400) == 0x00000400)) { + output.writeUInt32(13, expirationTimer_); + } + if (((bitField0_ & 0x00000800) == 0x00000800)) { + output.writeUInt64(14, lastDisappearingMessageChangeTimestamp_); + } getUnknownFields().writeTo(output); } @@ -3038,6 +3240,18 @@ public final class SignalServiceProtos { size += com.google.protobuf.CodedOutputStream .computeMessageSize(11, sharedConfigMessage_); } + if (((bitField0_ & 0x00000200) == 0x00000200)) { + size += com.google.protobuf.CodedOutputStream + .computeEnumSize(12, expirationType_.getNumber()); + } + if (((bitField0_ & 0x00000400) == 0x00000400)) { + size += com.google.protobuf.CodedOutputStream + .computeUInt32Size(13, expirationTimer_); + } + if (((bitField0_ & 0x00000800) == 0x00000800)) { + size += com.google.protobuf.CodedOutputStream + .computeUInt64Size(14, lastDisappearingMessageChangeTimestamp_); + } size += getUnknownFields().getSerializedSize(); memoizedSerializedSize = size; return size; @@ -3217,6 +3431,12 @@ public final class SignalServiceProtos { sharedConfigMessageBuilder_.clear(); } bitField0_ = (bitField0_ & ~0x00000100); + expirationType_ = org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType.UNKNOWN; + bitField0_ = (bitField0_ & ~0x00000200); + expirationTimer_ = 0; + bitField0_ = (bitField0_ & ~0x00000400); + lastDisappearingMessageChangeTimestamp_ = 0L; + bitField0_ = (bitField0_ & ~0x00000800); return this; } @@ -3317,6 +3537,18 @@ public final class SignalServiceProtos { } else { result.sharedConfigMessage_ = sharedConfigMessageBuilder_.build(); } + if (((from_bitField0_ & 0x00000200) == 0x00000200)) { + to_bitField0_ |= 0x00000200; + } + result.expirationType_ = expirationType_; + if (((from_bitField0_ & 0x00000400) == 0x00000400)) { + to_bitField0_ |= 0x00000400; + } + result.expirationTimer_ = expirationTimer_; + if (((from_bitField0_ & 0x00000800) == 0x00000800)) { + to_bitField0_ |= 0x00000800; + } + result.lastDisappearingMessageChangeTimestamp_ = lastDisappearingMessageChangeTimestamp_; result.bitField0_ = to_bitField0_; onBuilt(); return result; @@ -3360,6 +3592,15 @@ public final class SignalServiceProtos { if (other.hasSharedConfigMessage()) { mergeSharedConfigMessage(other.getSharedConfigMessage()); } + if (other.hasExpirationType()) { + setExpirationType(other.getExpirationType()); + } + if (other.hasExpirationTimer()) { + setExpirationTimer(other.getExpirationTimer()); + } + if (other.hasLastDisappearingMessageChangeTimestamp()) { + setLastDisappearingMessageChangeTimestamp(other.getLastDisappearingMessageChangeTimestamp()); + } this.mergeUnknownFields(other.getUnknownFields()); return this; } @@ -4494,6 +4735,108 @@ public final class SignalServiceProtos { return sharedConfigMessageBuilder_; } + // optional .signalservice.Content.ExpirationType expirationType = 12; + private org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType expirationType_ = org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType.UNKNOWN; + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + public boolean hasExpirationType() { + return ((bitField0_ & 0x00000200) == 0x00000200); + } + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + public org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType getExpirationType() { + return expirationType_; + } + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + public Builder setExpirationType(org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000200; + expirationType_ = value; + onChanged(); + return this; + } + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + public Builder clearExpirationType() { + bitField0_ = (bitField0_ & ~0x00000200); + expirationType_ = org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType.UNKNOWN; + onChanged(); + return this; + } + + // optional uint32 expirationTimer = 13; + private int expirationTimer_ ; + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + public boolean hasExpirationTimer() { + return ((bitField0_ & 0x00000400) == 0x00000400); + } + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + public int getExpirationTimer() { + return expirationTimer_; + } + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + public Builder setExpirationTimer(int value) { + bitField0_ |= 0x00000400; + expirationTimer_ = value; + onChanged(); + return this; + } + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + public Builder clearExpirationTimer() { + bitField0_ = (bitField0_ & ~0x00000400); + expirationTimer_ = 0; + onChanged(); + return this; + } + + // optional uint64 lastDisappearingMessageChangeTimestamp = 14; + private long lastDisappearingMessageChangeTimestamp_ ; + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + public boolean hasLastDisappearingMessageChangeTimestamp() { + return ((bitField0_ & 0x00000800) == 0x00000800); + } + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + public long getLastDisappearingMessageChangeTimestamp() { + return lastDisappearingMessageChangeTimestamp_; + } + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + public Builder setLastDisappearingMessageChangeTimestamp(long value) { + bitField0_ |= 0x00000800; + lastDisappearingMessageChangeTimestamp_ = value; + onChanged(); + return this; + } + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + public Builder clearLastDisappearingMessageChangeTimestamp() { + bitField0_ = (bitField0_ & ~0x00000800); + lastDisappearingMessageChangeTimestamp_ = 0L; + onChanged(); + return this; + } + // @@protoc_insertion_point(builder_scope:signalservice.Content) } @@ -27222,7 +27565,7 @@ public final class SignalServiceProtos { "\002(\004\0223\n\006action\030\002 \002(\0162#.signalservice.Typi" + "ngMessage.Action\"\"\n\006Action\022\013\n\007STARTED\020\000\022" + "\013\n\007STOPPED\020\001\"2\n\rUnsendRequest\022\021\n\ttimesta", - "mp\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\"\246\004\n\007Content\022/\n\013" + + "mp\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\"\373\005\n\007Content\022/\n\013" + "dataMessage\030\001 \001(\0132\032.signalservice.DataMe" + "ssage\022/\n\013callMessage\030\003 \001(\0132\032.signalservi" + "ce.CallMessage\0225\n\016receiptMessage\030\005 \001(\0132\035" + @@ -27236,103 +27579,108 @@ public final class SignalServiceProtos { "t\022E\n\026messageRequestResponse\030\n \001(\0132%.sign" + "alservice.MessageRequestResponse\022?\n\023shar" + "edConfigMessage\030\013 \001(\0132\".signalservice.Sh" + - "aredConfigMessage\"0\n\007KeyPair\022\021\n\tpublicKe" + - "y\030\001 \002(\014\022\022\n\nprivateKey\030\002 \002(\014\"\226\001\n\032DataExtr" + - "actionNotification\022<\n\004type\030\001 \002(\0162..signa" + - "lservice.DataExtractionNotification.Type" + - "\022\021\n\ttimestamp\030\002 \001(\004\"\'\n\004Type\022\016\n\nSCREENSHO" + - "T\020\001\022\017\n\013MEDIA_SAVED\020\002\"\231\016\n\013DataMessage\022\014\n\004", - "body\030\001 \001(\t\0225\n\013attachments\030\002 \003(\0132 .signal" + - "service.AttachmentPointer\022*\n\005group\030\003 \001(\013" + - "2\033.signalservice.GroupContext\022\r\n\005flags\030\004" + - " \001(\r\022\023\n\013expireTimer\030\005 \001(\r\022\022\n\nprofileKey\030" + - "\006 \001(\014\022\021\n\ttimestamp\030\007 \001(\004\022/\n\005quote\030\010 \001(\0132" + - " .signalservice.DataMessage.Quote\0223\n\007pre" + - "view\030\n \003(\0132\".signalservice.DataMessage.P" + - "review\0225\n\010reaction\030\013 \001(\0132#.signalservice" + - ".DataMessage.Reaction\0227\n\007profile\030e \001(\0132&" + - ".signalservice.DataMessage.LokiProfile\022K", - "\n\023openGroupInvitation\030f \001(\0132..signalserv" + - "ice.DataMessage.OpenGroupInvitation\022W\n\031c" + - "losedGroupControlMessage\030h \001(\01324.signals" + - "ervice.DataMessage.ClosedGroupControlMes" + - "sage\022\022\n\nsyncTarget\030i \001(\t\022&\n\036blocksCommun" + - "ityMessageRequests\030j \001(\010\032\225\002\n\005Quote\022\n\n\002id" + - "\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\022\014\n\004text\030\003 \001(\t\022F\n\013" + - "attachments\030\004 \003(\01321.signalservice.DataMe" + - "ssage.Quote.QuotedAttachment\032\231\001\n\020QuotedA" + - "ttachment\022\023\n\013contentType\030\001 \001(\t\022\020\n\010fileNa", - "me\030\002 \001(\t\0223\n\tthumbnail\030\003 \001(\0132 .signalserv" + - "ice.AttachmentPointer\022\r\n\005flags\030\004 \001(\r\"\032\n\005" + - "Flags\022\021\n\rVOICE_MESSAGE\020\001\032V\n\007Preview\022\013\n\003u" + - "rl\030\001 \002(\t\022\r\n\005title\030\002 \001(\t\022/\n\005image\030\003 \001(\0132 " + - ".signalservice.AttachmentPointer\032:\n\013Loki" + - "Profile\022\023\n\013displayName\030\001 \001(\t\022\026\n\016profileP" + - "icture\030\002 \001(\t\0320\n\023OpenGroupInvitation\022\013\n\003u" + - "rl\030\001 \002(\t\022\014\n\004name\030\003 \002(\t\032\374\003\n\031ClosedGroupCo" + - "ntrolMessage\022G\n\004type\030\001 \002(\01629.signalservi" + - "ce.DataMessage.ClosedGroupControlMessage", - ".Type\022\021\n\tpublicKey\030\002 \001(\014\022\014\n\004name\030\003 \001(\t\0221" + - "\n\021encryptionKeyPair\030\004 \001(\0132\026.signalservic" + - "e.KeyPair\022\017\n\007members\030\005 \003(\014\022\016\n\006admins\030\006 \003" + - "(\014\022U\n\010wrappers\030\007 \003(\0132C.signalservice.Dat" + - "aMessage.ClosedGroupControlMessage.KeyPa" + - "irWrapper\022\027\n\017expirationTimer\030\010 \001(\r\032=\n\016Ke" + - "yPairWrapper\022\021\n\tpublicKey\030\001 \002(\014\022\030\n\020encry" + - "ptedKeyPair\030\002 \002(\014\"r\n\004Type\022\007\n\003NEW\020\001\022\027\n\023EN" + - "CRYPTION_KEY_PAIR\020\003\022\017\n\013NAME_CHANGE\020\004\022\021\n\r" + - "MEMBERS_ADDED\020\005\022\023\n\017MEMBERS_REMOVED\020\006\022\017\n\013", - "MEMBER_LEFT\020\007\032\222\001\n\010Reaction\022\n\n\002id\030\001 \002(\004\022\016" + - "\n\006author\030\002 \002(\t\022\r\n\005emoji\030\003 \001(\t\022:\n\006action\030" + - "\004 \002(\0162*.signalservice.DataMessage.Reacti" + - "on.Action\"\037\n\006Action\022\t\n\005REACT\020\000\022\n\n\006REMOVE" + - "\020\001\"$\n\005Flags\022\033\n\027EXPIRATION_TIMER_UPDATE\020\002" + - "\"\352\001\n\013CallMessage\022-\n\004type\030\001 \002(\0162\037.signals" + - "ervice.CallMessage.Type\022\014\n\004sdps\030\002 \003(\t\022\027\n" + - "\017sdpMLineIndexes\030\003 \003(\r\022\017\n\007sdpMids\030\004 \003(\t\022" + - "\014\n\004uuid\030\005 \002(\t\"f\n\004Type\022\r\n\tPRE_OFFER\020\006\022\t\n\005" + - "OFFER\020\001\022\n\n\006ANSWER\020\002\022\026\n\022PROVISIONAL_ANSWE", - "R\020\003\022\022\n\016ICE_CANDIDATES\020\004\022\014\n\010END_CALL\020\005\"\245\004" + - "\n\024ConfigurationMessage\022E\n\014closedGroups\030\001" + - " \003(\0132/.signalservice.ConfigurationMessag" + - "e.ClosedGroup\022\022\n\nopenGroups\030\002 \003(\t\022\023\n\013dis" + - "playName\030\003 \001(\t\022\026\n\016profilePicture\030\004 \001(\t\022\022" + - "\n\nprofileKey\030\005 \001(\014\022=\n\010contacts\030\006 \003(\0132+.s" + - "ignalservice.ConfigurationMessage.Contac" + - "t\032\233\001\n\013ClosedGroup\022\021\n\tpublicKey\030\001 \001(\014\022\014\n\004" + - "name\030\002 \001(\t\0221\n\021encryptionKeyPair\030\003 \001(\0132\026." + - "signalservice.KeyPair\022\017\n\007members\030\004 \003(\014\022\016", - "\n\006admins\030\005 \003(\014\022\027\n\017expirationTimer\030\006 \001(\r\032" + - "\223\001\n\007Contact\022\021\n\tpublicKey\030\001 \002(\014\022\014\n\004name\030\002" + - " \002(\t\022\026\n\016profilePicture\030\003 \001(\t\022\022\n\nprofileK" + - "ey\030\004 \001(\014\022\022\n\nisApproved\030\005 \001(\010\022\021\n\tisBlocke" + - "d\030\006 \001(\010\022\024\n\014didApproveMe\030\007 \001(\010\"y\n\026Message" + - "RequestResponse\022\022\n\nisApproved\030\001 \002(\010\022\022\n\np" + - "rofileKey\030\002 \001(\014\0227\n\007profile\030\003 \001(\0132&.signa" + - "lservice.DataMessage.LokiProfile\"\375\001\n\023Sha" + - "redConfigMessage\0225\n\004kind\030\001 \002(\0162\'.signals" + - "ervice.SharedConfigMessage.Kind\022\r\n\005seqno", - "\030\002 \002(\003\022\014\n\004data\030\003 \002(\014\"\221\001\n\004Kind\022\020\n\014USER_PR" + - "OFILE\020\001\022\014\n\010CONTACTS\020\002\022\027\n\023CONVO_INFO_VOLA" + - "TILE\020\003\022\n\n\006GROUPS\020\004\022\025\n\021CLOSED_GROUP_INFO\020" + - "\005\022\030\n\024CLOSED_GROUP_MEMBERS\020\006\022\023\n\017ENCRYPTIO" + - "N_KEYS\020\007\"u\n\016ReceiptMessage\0220\n\004type\030\001 \002(\016" + - "2\".signalservice.ReceiptMessage.Type\022\021\n\t" + - "timestamp\030\002 \003(\004\"\036\n\004Type\022\014\n\010DELIVERY\020\000\022\010\n" + - "\004READ\020\001\"\354\001\n\021AttachmentPointer\022\n\n\002id\030\001 \002(" + - "\006\022\023\n\013contentType\030\002 \001(\t\022\013\n\003key\030\003 \001(\014\022\014\n\004s" + - "ize\030\004 \001(\r\022\021\n\tthumbnail\030\005 \001(\014\022\016\n\006digest\030\006", - " \001(\014\022\020\n\010fileName\030\007 \001(\t\022\r\n\005flags\030\010 \001(\r\022\r\n" + - "\005width\030\t \001(\r\022\016\n\006height\030\n \001(\r\022\017\n\007caption\030" + - "\013 \001(\t\022\013\n\003url\030e \001(\t\"\032\n\005Flags\022\021\n\rVOICE_MES" + - "SAGE\020\001\"\365\001\n\014GroupContext\022\n\n\002id\030\001 \001(\014\022.\n\004t" + - "ype\030\002 \001(\0162 .signalservice.GroupContext.T" + - "ype\022\014\n\004name\030\003 \001(\t\022\017\n\007members\030\004 \003(\t\0220\n\006av" + - "atar\030\005 \001(\0132 .signalservice.AttachmentPoi" + - "nter\022\016\n\006admins\030\006 \003(\t\"H\n\004Type\022\013\n\007UNKNOWN\020" + - "\000\022\n\n\006UPDATE\020\001\022\013\n\007DELIVER\020\002\022\010\n\004QUIT\020\003\022\020\n\014" + - "REQUEST_INFO\020\004B3\n\034org.session.libsignal.", - "protosB\023SignalServiceProtos" + "aredConfigMessage\022=\n\016expirationType\030\014 \001(" + + "\0162%.signalservice.Content.ExpirationType" + + "\022\027\n\017expirationTimer\030\r \001(\r\022.\n&lastDisappe" + + "aringMessageChangeTimestamp\030\016 \001(\004\"K\n\016Exp" + + "irationType\022\013\n\007UNKNOWN\020\000\022\025\n\021DELETE_AFTER" + + "_READ\020\001\022\025\n\021DELETE_AFTER_SEND\020\002\"0\n\007KeyPai", + "r\022\021\n\tpublicKey\030\001 \002(\014\022\022\n\nprivateKey\030\002 \002(\014" + + "\"\226\001\n\032DataExtractionNotification\022<\n\004type\030" + + "\001 \002(\0162..signalservice.DataExtractionNoti" + + "fication.Type\022\021\n\ttimestamp\030\002 \001(\004\"\'\n\004Type" + + "\022\016\n\nSCREENSHOT\020\001\022\017\n\013MEDIA_SAVED\020\002\"\231\016\n\013Da" + + "taMessage\022\014\n\004body\030\001 \001(\t\0225\n\013attachments\030\002" + + " \003(\0132 .signalservice.AttachmentPointer\022*" + + "\n\005group\030\003 \001(\0132\033.signalservice.GroupConte" + + "xt\022\r\n\005flags\030\004 \001(\r\022\023\n\013expireTimer\030\005 \001(\r\022\022" + + "\n\nprofileKey\030\006 \001(\014\022\021\n\ttimestamp\030\007 \001(\004\022/\n", + "\005quote\030\010 \001(\0132 .signalservice.DataMessage" + + ".Quote\0223\n\007preview\030\n \003(\0132\".signalservice." + + "DataMessage.Preview\0225\n\010reaction\030\013 \001(\0132#." + + "signalservice.DataMessage.Reaction\0227\n\007pr" + + "ofile\030e \001(\0132&.signalservice.DataMessage." + + "LokiProfile\022K\n\023openGroupInvitation\030f \001(\013" + + "2..signalservice.DataMessage.OpenGroupIn" + + "vitation\022W\n\031closedGroupControlMessage\030h " + + "\001(\01324.signalservice.DataMessage.ClosedGr" + + "oupControlMessage\022\022\n\nsyncTarget\030i \001(\t\022&\n", + "\036blocksCommunityMessageRequests\030j \001(\010\032\225\002" + + "\n\005Quote\022\n\n\002id\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\022\014\n\004t" + + "ext\030\003 \001(\t\022F\n\013attachments\030\004 \003(\01321.signals" + + "ervice.DataMessage.Quote.QuotedAttachmen" + + "t\032\231\001\n\020QuotedAttachment\022\023\n\013contentType\030\001 " + + "\001(\t\022\020\n\010fileName\030\002 \001(\t\0223\n\tthumbnail\030\003 \001(\013" + + "2 .signalservice.AttachmentPointer\022\r\n\005fl" + + "ags\030\004 \001(\r\"\032\n\005Flags\022\021\n\rVOICE_MESSAGE\020\001\032V\n" + + "\007Preview\022\013\n\003url\030\001 \002(\t\022\r\n\005title\030\002 \001(\t\022/\n\005" + + "image\030\003 \001(\0132 .signalservice.AttachmentPo", + "inter\032:\n\013LokiProfile\022\023\n\013displayName\030\001 \001(" + + "\t\022\026\n\016profilePicture\030\002 \001(\t\0320\n\023OpenGroupIn" + + "vitation\022\013\n\003url\030\001 \002(\t\022\014\n\004name\030\003 \002(\t\032\374\003\n\031" + + "ClosedGroupControlMessage\022G\n\004type\030\001 \002(\0162" + + "9.signalservice.DataMessage.ClosedGroupC" + + "ontrolMessage.Type\022\021\n\tpublicKey\030\002 \001(\014\022\014\n" + + "\004name\030\003 \001(\t\0221\n\021encryptionKeyPair\030\004 \001(\0132\026" + + ".signalservice.KeyPair\022\017\n\007members\030\005 \003(\014\022" + + "\016\n\006admins\030\006 \003(\014\022U\n\010wrappers\030\007 \003(\0132C.sign" + + "alservice.DataMessage.ClosedGroupControl", + "Message.KeyPairWrapper\022\027\n\017expirationTime" + + "r\030\010 \001(\r\032=\n\016KeyPairWrapper\022\021\n\tpublicKey\030\001" + + " \002(\014\022\030\n\020encryptedKeyPair\030\002 \002(\014\"r\n\004Type\022\007" + + "\n\003NEW\020\001\022\027\n\023ENCRYPTION_KEY_PAIR\020\003\022\017\n\013NAME" + + "_CHANGE\020\004\022\021\n\rMEMBERS_ADDED\020\005\022\023\n\017MEMBERS_" + + "REMOVED\020\006\022\017\n\013MEMBER_LEFT\020\007\032\222\001\n\010Reaction\022" + + "\n\n\002id\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\022\r\n\005emoji\030\003 \001" + + "(\t\022:\n\006action\030\004 \002(\0162*.signalservice.DataM" + + "essage.Reaction.Action\"\037\n\006Action\022\t\n\005REAC" + + "T\020\000\022\n\n\006REMOVE\020\001\"$\n\005Flags\022\033\n\027EXPIRATION_T", + "IMER_UPDATE\020\002\"\352\001\n\013CallMessage\022-\n\004type\030\001 " + + "\002(\0162\037.signalservice.CallMessage.Type\022\014\n\004" + + "sdps\030\002 \003(\t\022\027\n\017sdpMLineIndexes\030\003 \003(\r\022\017\n\007s" + + "dpMids\030\004 \003(\t\022\014\n\004uuid\030\005 \002(\t\"f\n\004Type\022\r\n\tPR" + + "E_OFFER\020\006\022\t\n\005OFFER\020\001\022\n\n\006ANSWER\020\002\022\026\n\022PROV" + + "ISIONAL_ANSWER\020\003\022\022\n\016ICE_CANDIDATES\020\004\022\014\n\010" + + "END_CALL\020\005\"\245\004\n\024ConfigurationMessage\022E\n\014c" + + "losedGroups\030\001 \003(\0132/.signalservice.Config" + + "urationMessage.ClosedGroup\022\022\n\nopenGroups" + + "\030\002 \003(\t\022\023\n\013displayName\030\003 \001(\t\022\026\n\016profilePi", + "cture\030\004 \001(\t\022\022\n\nprofileKey\030\005 \001(\014\022=\n\010conta" + + "cts\030\006 \003(\0132+.signalservice.ConfigurationM" + + "essage.Contact\032\233\001\n\013ClosedGroup\022\021\n\tpublic" + + "Key\030\001 \001(\014\022\014\n\004name\030\002 \001(\t\0221\n\021encryptionKey" + + "Pair\030\003 \001(\0132\026.signalservice.KeyPair\022\017\n\007me" + + "mbers\030\004 \003(\014\022\016\n\006admins\030\005 \003(\014\022\027\n\017expiratio" + + "nTimer\030\006 \001(\r\032\223\001\n\007Contact\022\021\n\tpublicKey\030\001 " + + "\002(\014\022\014\n\004name\030\002 \002(\t\022\026\n\016profilePicture\030\003 \001(" + + "\t\022\022\n\nprofileKey\030\004 \001(\014\022\022\n\nisApproved\030\005 \001(" + + "\010\022\021\n\tisBlocked\030\006 \001(\010\022\024\n\014didApproveMe\030\007 \001", + "(\010\"y\n\026MessageRequestResponse\022\022\n\nisApprov" + + "ed\030\001 \002(\010\022\022\n\nprofileKey\030\002 \001(\014\0227\n\007profile\030" + + "\003 \001(\0132&.signalservice.DataMessage.LokiPr" + + "ofile\"\375\001\n\023SharedConfigMessage\0225\n\004kind\030\001 " + + "\002(\0162\'.signalservice.SharedConfigMessage." + + "Kind\022\r\n\005seqno\030\002 \002(\003\022\014\n\004data\030\003 \002(\014\"\221\001\n\004Ki" + + "nd\022\020\n\014USER_PROFILE\020\001\022\014\n\010CONTACTS\020\002\022\027\n\023CO" + + "NVO_INFO_VOLATILE\020\003\022\n\n\006GROUPS\020\004\022\025\n\021CLOSE" + + "D_GROUP_INFO\020\005\022\030\n\024CLOSED_GROUP_MEMBERS\020\006" + + "\022\023\n\017ENCRYPTION_KEYS\020\007\"u\n\016ReceiptMessage\022", + "0\n\004type\030\001 \002(\0162\".signalservice.ReceiptMes" + + "sage.Type\022\021\n\ttimestamp\030\002 \003(\004\"\036\n\004Type\022\014\n\010" + + "DELIVERY\020\000\022\010\n\004READ\020\001\"\354\001\n\021AttachmentPoint" + + "er\022\n\n\002id\030\001 \002(\006\022\023\n\013contentType\030\002 \001(\t\022\013\n\003k" + + "ey\030\003 \001(\014\022\014\n\004size\030\004 \001(\r\022\021\n\tthumbnail\030\005 \001(" + + "\014\022\016\n\006digest\030\006 \001(\014\022\020\n\010fileName\030\007 \001(\t\022\r\n\005f" + + "lags\030\010 \001(\r\022\r\n\005width\030\t \001(\r\022\016\n\006height\030\n \001(" + + "\r\022\017\n\007caption\030\013 \001(\t\022\013\n\003url\030e \001(\t\"\032\n\005Flags" + + "\022\021\n\rVOICE_MESSAGE\020\001\"\365\001\n\014GroupContext\022\n\n\002" + + "id\030\001 \001(\014\022.\n\004type\030\002 \001(\0162 .signalservice.G", + "roupContext.Type\022\014\n\004name\030\003 \001(\t\022\017\n\007member" + + "s\030\004 \003(\t\0220\n\006avatar\030\005 \001(\0132 .signalservice." + + "AttachmentPointer\022\016\n\006admins\030\006 \003(\t\"H\n\004Typ" + + "e\022\013\n\007UNKNOWN\020\000\022\n\n\006UPDATE\020\001\022\013\n\007DELIVER\020\002\022" + + "\010\n\004QUIT\020\003\022\020\n\014REQUEST_INFO\020\004B3\n\034org.sessi" + + "on.libsignal.protosB\023SignalServiceProtos" }; com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner assigner = new com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner() { @@ -27362,7 +27710,7 @@ public final class SignalServiceProtos { internal_static_signalservice_Content_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_Content_descriptor, - new java.lang.String[] { "DataMessage", "CallMessage", "ReceiptMessage", "TypingMessage", "ConfigurationMessage", "DataExtractionNotification", "UnsendRequest", "MessageRequestResponse", "SharedConfigMessage", }); + new java.lang.String[] { "DataMessage", "CallMessage", "ReceiptMessage", "TypingMessage", "ConfigurationMessage", "DataExtractionNotification", "UnsendRequest", "MessageRequestResponse", "SharedConfigMessage", "ExpirationType", "ExpirationTimer", "LastDisappearingMessageChangeTimestamp", }); internal_static_signalservice_KeyPair_descriptor = getDescriptor().getMessageTypes().get(4); internal_static_signalservice_KeyPair_fieldAccessorTable = new diff --git a/scripts/drone-static-upload.sh b/scripts/drone-static-upload.sh new file mode 100755 index 0000000000..b5c9ee83f7 --- /dev/null +++ b/scripts/drone-static-upload.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +# Script used with Drone CI to upload build artifacts (because specifying all this in +# .drone.jsonnet is too painful). + +set -o errexit + +if [ -z "$SSH_KEY" ]; then + echo -e "\n\n\n\e[31;1mUnable to upload artifact: SSH_KEY not set\e[0m" + # Just warn but don't fail, so that this doesn't trigger a build failure for untrusted builds + exit 0 +fi + +echo "$SSH_KEY" >ssh_key + +set -o xtrace # Don't start tracing until *after* we write the ssh key + +chmod 600 ssh_key + +# Define the output paths +build_dir="app/build/outputs/apk/play/debug" +target_path="${build_dir}/$(ls ${build_dir} | grep -o 'session-[^[:space:]]*-universal.apk')" + +# Validate the paths exist +if [ ! -d $build_path ]; then + echo -e "\n\n\n\e[31;1mExpected a file to upload, found none\e[0m" >&2 + exit 1 +fi + +if [ -n "$DRONE_TAG" ]; then + # For a tag build use something like `session-android-v1.2.3-universal` + base="session-android-$DRONE_TAG-universal" +else + # Otherwise build a length name from the datetime and commit hash, such as: + # session-android-20200522T212342Z-04d7dcc54-universal + base="session-android-$(date --date=@$DRONE_BUILD_CREATED +%Y%m%dT%H%M%SZ)-${DRONE_COMMIT:0:9}-universal" +fi + +# Copy over the build products +mkdir -vp "$base" +cp -av $target_path "$base" + +# tar dat shiz up yo +archive="$base.tar.xz" +tar cJvf "$archive" "$base" + +upload_to="oxen.rocks/${DRONE_REPO// /_}/${DRONE_BRANCH// /_}" + +# sftp doesn't have any equivalent to mkdir -p, so we have to split the above up into a chain of +# -mkdir a/, -mkdir a/b/, -mkdir a/b/c/, ... commands. The leading `-` allows the command to fail +# without error. +upload_dirs=(${upload_to//\// }) +put_debug= +mkdirs= +dir_tmp="" +for p in "${upload_dirs[@]}"; do + dir_tmp="$dir_tmp$p/" + mkdirs="$mkdirs +-mkdir $dir_tmp" +done + +sftp -i ssh_key -b - -o StrictHostKeyChecking=off drone@oxen.rocks <<SFTP +$mkdirs +put $archive $upload_to +$put_debug +SFTP + +set +o xtrace + +echo -e "\n\n\n\n\e[32;1mUploaded to https://${upload_to}/${archive}\e[0m\n\n\n" diff --git a/scripts/drone-upload-exists.sh b/scripts/drone-upload-exists.sh new file mode 100755 index 0000000000..bc9b112c26 --- /dev/null +++ b/scripts/drone-upload-exists.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# +# Script used with Drone CI to check for the existence of a build artifact. + +if [[ -z ${DRONE_REPO} || -z ${DRONE_PULL_REQUEST} ]]; then + echo -e "\n\n\n\n\e[31;1mRequired env variables not specified, likely a tag build so just failing\e[0m\n\n\n" + exit 1 +fi + +# This file info MUST match the structure of `base` in the `drone-static-upload.sh` script in +# order to function correctly +prefix="session-android-" +suffix="-${DRONE_COMMIT:0:9}-universal.tar.xz" + +# Extracting head.label using string manipulation +echo "Extracting repo information for 'https://api.github.com/repos/${DRONE_REPO}/pulls/${DRONE_PULL_REQUEST}'" +pr_info=$(curl -s https://api.github.com/repos/${DRONE_REPO}/pulls/${DRONE_PULL_REQUEST}) +pr_info_clean=$(echo "$pr_info" | tr -d '[:space:]') +head_info=$(echo "$pr_info_clean" | sed -n 's/.*"head"\(.*\)"base".*/\1/p') +fork_repo=$(echo "$head_info" | grep -o '"full_name":"[^"]*' | sed 's/"full_name":"//') +fork_branch=$(echo "$head_info" | grep -o '"ref":"[^"]*' | sed 's/"ref":"//') +upload_dir="https://oxen.rocks/${fork_repo}/${fork_branch}" + +echo "Starting to poll ${upload_dir}/ every 10s to check for a build matching '${prefix}.*${suffix}'" + +# Loop indefinitely the CI can timeout the script if it takes too long +total_poll_duration=0 +max_poll_duration=$((30 * 60)) # Poll for a maximum of 30 mins + +while true; do + # Need to add the trailing '/' or else we get a '301' response + build_artifacts_html=$(curl -s "${upload_dir}/") + + if [ $? != 0 ]; then + echo -e "\n\n\n\n\e[31;1mFailed to retrieve build artifact list\e[0m\n\n\n" + exit 1 + fi + + # Extract 'session-ios...' titles using grep and awk then look for the target file + current_build_artifacts=$(echo "$build_artifacts_html" | grep -o "href=\"${prefix}[^\"]*" | sed 's/href="//') + target_file=$(echo "$current_build_artifacts" | grep -o "${prefix}.*${suffix}" | tail -n 1) + + if [ -n "$target_file" ]; then + echo -e "\n\n\n\n\e[32;1mExisting build artifact at ${upload_dir}/${target_file}\e[0m\n\n\n" + exit 0 + fi + + # Sleep for 10 seconds before checking again + sleep 10 + total_poll_duration=$((total_poll_duration + 10)) + + if [ $total_poll_duration -gt $max_poll_duration ]; then + echo -e "\n\n\n\n\e[31;1mCould not find existing build artifact after polling for 30 minutes\e[0m\n\n\n" + exit 1 + fi +done