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] '
+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): 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): 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 @@
+
+
@@ -176,6 +178,9 @@
android:screenOrientation="portrait" />
+
-
+ android:exported="false" android:foregroundServiceType="specialUse">
+
+
+
Unit
-): AlertDialog {
- val view = LayoutInflater.from(this).inflate(R.layout.expiration_dialog, null)
- val numberPickerView = view.findViewById(R.id.expiration_number_picker)
-
- fun updateText(index: Int) {
- view.findViewById(R.id.expiration_details).text = when (index) {
- 0 -> getString(R.string.ExpirationDialog_your_messages_will_not_expire)
- else -> getString(
- R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen,
- numberPickerView.displayedValues[index]
- )
- }
- }
-
- val expirationTimes = resources.getIntArray(R.array.expiration_times)
- val expirationDisplayValues = expirationTimes
- .map { ExpirationUtil.getExpirationDisplayValue(this, it) }
- .toTypedArray()
-
- val selectedIndex = expirationTimes.run { indexOfFirst { it >= expiration }.coerceIn(indices) }
-
- numberPickerView.apply {
- displayedValues = expirationDisplayValues
- minValue = 0
- maxValue = expirationTimes.lastIndex
- setOnValueChangedListener { _, _, index -> updateText(index) }
- value = selectedIndex
- }
-
- updateText(selectedIndex)
-
- return showSessionDialog {
- title(getString(R.string.ExpirationDialog_disappearing_messages))
- view(view)
- okButton { onExpirationTime(numberPickerView.let { expirationTimes[it.value] }) }
- cancelButton()
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/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, 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() {
- @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.toAdapterItems(): List {
- return this.mapIndexed { index, item ->
- val displayType: DisplayType = when {
- this.size == 1 -> DisplayType.ONLY
+ private fun List.toAdapterItems(): List =
+ 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 {
- 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(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 {
+ marginEnd = if (recipient.showCallMenu()) 0 else binding.profilePictureView.width
+ }
+ }
+
+ fun updateSubtitle(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) {
+ val settings = mutableListOf()
+ 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(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() {
+ 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()
+ 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 create(modelClass: Class): 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? = if (typeOptionsHidden) null else {
+ buildList {
+ add(offTypeOption())
+ if (!isNewConfigEnabled) add(legacyTypeOption())
+ if (!isGroup) add(afterReadTypeOption())
+ add(afterSendTypeOption())
+ }
+}
+
+private fun State.timeOptions(): List? {
+ // 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 =
+ 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
+typealias ExpiryRadioOption = RadioOption
+
+@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 {
+ 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
+
+data class UiState(
+ val cards: List = 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(
+ val title: GetString,
+ val options: List>
+) {
+ constructor(title: GetString, vararg options: RadioOption): this(title, options.asList())
+ constructor(@StringRes title: Int, vararg options: RadioOption): 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,
+ SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks, 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) {
@@ -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, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null): Pair? {
+ private fun sendAttachments(
+ attachments: List,
+ body: String?,
+ quotedMessage: MessageRecord? = binding?.inputBar?.quote,
+ linkPreview: LinkPreview? = null
+ ): Pair? {
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 {
@@ -1810,7 +1802,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun deleteMessages(messages: Set) {
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) {
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 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 getMenuActionItems(@NonNull MessageRecord message) {
- List 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 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 newHideAnimators() {
- int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration);
-
- List 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
+ 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(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 {
+ val items: MutableList = 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 {
+ 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 = 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 = 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 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(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(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(R.id.menu_badge_icon)
- val badgeView = actionView.findViewById(R.id.expiration_badge)
- @ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary)
- iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY)
- badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages)
- actionView.setOnClickListener { onOptionsItemSelected(item) }
- }
- } else {
- inflater.inflate(R.menu.menu_conversation_expiration_off, menu)
- }
+ 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() {
+ @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(thread.name)
.or(Optional.fromNullable(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 {
+ 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 {
+ 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 {
+ gravity = if (message.isOutgoing) Gravity.END else Gravity.START
+ }
+
+ binding.statusContainer.modifyLayoutParams {
+ 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 = 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) {
- val database = databaseHelper.writableDatabase
- database.delete(
- messageHashTable,
- "${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})",
+ fun deleteMessageServerHashes(messageIDs: List, 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 {
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 {
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 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 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 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 = if (quotes != null) Optional.of(quotes) else Optional.absent()
val linkPreviews: Optional> = 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? {
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, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) {
+ override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map, 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, admins: Collection, 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, admins: Collection, 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) {
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) {
+ override fun addLibSessionContacts(contacts: List, 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): List> {
+ val expiringMessages = mutableListOf>()
+ 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 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 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, val zombieMembers: List)
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> callback) {
- SignalExecutors.BOUNDED.execute(() -> {
- if (isMms) {
- callback.onComplete(getMmsLongMessage(context, mmsDatabase, messageId));
- } else {
- callback.onComplete(getSmsLongMessage(smsDatabase, messageId));
- }
- });
- }
-
- @WorkerThread
- private Optional getMmsLongMessage(@NonNull Context context, @NonNull MmsDatabase mmsDatabase, long messageId) {
- Optional 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 getSmsLongMessage(@NonNull SmsDatabase smsDatabase, long messageId) {
- Optional record = getSmsMessage(smsDatabase, messageId);
-
- if (record.isPresent()) {
- return Optional.of(new LongMessage(record.get(), ""));
- } else {
- return Optional.absent();
- }
- }
-
-
- @WorkerThread
- private Optional getMmsMessage(@NonNull MmsDatabase mmsDatabase, long messageId) {
- try (Cursor cursor = mmsDatabase.getMessage(messageId)) {
- return Optional.fromNullable((MmsMessageRecord) mmsDatabase.readerFor(cursor).getNext());
- }
- }
-
- @WorkerThread
- private Optional 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 {
- 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> 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> 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 create(@NonNull Class 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() {
- @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 markedReadMessages) {
- if (markedReadMessages.isEmpty()) return;
-
- for (MarkedMessageInfo messageInfo : markedReadMessages) {
- scheduleDeletion(context, messageInfo.getExpirationInfo());
- }
-
- if (!TextSecurePreferences.isReadReceiptsEnabled(context)) return;
-
- Map> addressMap = Stream.of(markedReadMessages)
- .map(MarkedMessageInfo::getSyncMessageId)
- .collect(Collectors.groupingBy(SyncMessageId::getAddress));
-
- for (Address address : addressMap.keySet()) {
- List 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() {
+ 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
+ ) {
+ 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
+ ): Map? {
+ 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
+ ) {
+ 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
+ ) {
+ 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
+ ) {
+ @Suppress("UNCHECKED_CAST")
+ val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), TextSecurePreferences.getLocalNumber(context)!!).get()["expiries"] as Map
+ 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?) {
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 = 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,
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(RadioOptionDiffer()) {
+class RadioOptionAdapter(
+ private var selectedOptionPosition: Int = 0,
+ private val onClickListener: (RadioOption) -> Unit
+) : ListAdapter, RadioOptionAdapter.ViewHolder>(RadioOptionDiffer()) {
- class RadioOptionDiffer: DiffUtil.ItemCallback() {
- override fun areItemsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.title == newItem.title
- override fun areContentsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.value == newItem.value
+ class RadioOptionDiffer: DiffUtil.ItemCallback>() {
+ override fun areItemsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.title == newItem.title
+ override fun areContentsTheSame(oldItem: RadioOption, newItem: RadioOption) = 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 =
+ 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, 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(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, isSelected: Boolean, toggleSelection: (RadioOption) -> 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(
+ val value: T,
+ val title: GetString,
+ val subtitle: GetString? = null,
+ val enabled: Boolean = true,
+ val contentDescription: GetString? = null
)
+
+fun radioOption(value: T, @StringRes title: Int, configure: RadioOptionBuilder.() -> Unit = {}) =
+ radioOption(value, GetString(title), configure)
+
+fun radioOption(value: T, title: String, configure: RadioOptionBuilder.() -> Unit = {}) =
+ radioOption(value, GetString(title), configure)
+
+fun radioOption(value: T, title: GetString, configure: RadioOptionBuilder.() -> Unit = {}) =
+ RadioOptionBuilder(value, title).also { it.configure() }.build()
+
+class RadioOptionBuilder(
+ 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
fun recipientUpdateFlow(threadId: Long): Flow
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 =
+ contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId))
+
override fun recipientUpdateFlow(threadId: Long): Flow {
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 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 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 {
- @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()
+ 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()
+ 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 {
+ 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 {
+ fun onSetClick(): Any?
+ fun setValue(value: T)
+}
+
+object NoOpCallbacks: Callbacks {
+ override fun onSetClick() {}
+ override fun setValue(value: Any) {}
+}
+
+data class RadioOption(
+ val value: T,
+ val title: GetString,
+ val subtitle: GetString? = null,
+ val contentDescription: GetString = title,
+ val selected: Boolean = false,
+ val enabled: Boolean = true,
+)
+
+@Composable
+fun OptionsCard(card: OptionsCard, callbacks: Callbacks) {
+ 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 TitledRadioButton(option: RadioOption, 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(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 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 { 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 {
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) {
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 {
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 @@
+
+
+
+
+
+
+
\ 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 @@
+
+
+
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 @@
+
+
+
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 @@
-
-
-
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 @@
+
+
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 @@
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
\ 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">
-
+
@@ -216,6 +218,29 @@
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ 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 @@
+
+
+
+
+
+
+
+
\ 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"/>
-
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+
+
+
+
+
\ 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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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 @@
-
-
-
-
-
-
-
-
\ 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 @@
-
-
-
-
-
-
-
-
\ 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 @@
-
@@ -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" />
+
+
+ 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"/>
-
\ No newline at end of file
+
\ 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 @@
-
-
-
-
-
-
-
-
-
-
\ 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"/>
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ 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">
+
+
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" />
-
+
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 @@
+
+
+
+
+
+
+
+
+
+
\ 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" />
-
-
@@ -150,28 +138,41 @@
app:layout_constraintStart_toStartOf="@+id/messageInnerContainer"
app:layout_constraintTop_toBottomOf="@id/messageInnerContainer" />
-
-
-
+ app:layout_constraintStart_toStartOf="@id/messageInnerContainer"
+ app:layout_constraintHorizontal_bias="1"
+ app:layout_constraintEnd_toEndOf="parent">
+
+
+
+
+
+
+
+
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"/>
-
-
+
+
+ android:id="@+id/menu_expiring_messages"
+ android:contentDescription="@string/AccessibilityId_disappearing_messages" />
\ 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 @@
-
-
\ 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 @@
-
-
-
-
-
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 @@
#FF3A3A
#E12D19
+ #00F782
+
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 @@
+ 9sp
12sp
15sp
17sp
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 @@
New conversation button
New direct message
Create group
- Join community
+ Join community button
+
+ Community input
+ Join community button
All media
Search
@@ -82,14 +85,14 @@
Call button
Settings
- Disappearing messages timer
+ Confirm
Time selector
Accept message request
Decline message request
Block message request
Timer icon
- Configuration message
+ Control message
Blocked banner
Blocked banner text
@@ -114,11 +117,9 @@
Download media
Don\'t download media
- Message sent status: Sent
- Message sent status pending
- Message sent status syncing
+ Message sent status: Sent
Message request has been accepted
- Message Body
+ Message body
Voice message
Document
Deleted message
@@ -153,6 +154,17 @@
Enable
Cancel
+
+
+ Disappear after read option
+ Disappear after send option
+ Disappearing messages timer
+ Set button
+ Time option
+ Disable disappearing messages
+ Configuration message
+ Disappearing messages type and time
+ Conversation header name
New message
@@ -467,6 +479,7 @@
Record and send audio attachment
Lock recording of audio attachment
Enable Session for SMS
+ Please wait until attachment has finished downloading
Slide to cancel
Cancel
@@ -556,6 +569,12 @@
Default
High
Max
+ 5 Minutes
+ 1 Hour
+ 12 Hours
+ 1 Day
+ 1 Week
+ 2 Weeks
- %d hour
@@ -890,6 +909,11 @@
Are you sure you want to join the %s open group?
Open URL?
Are you sure you want to open %s?
+ Follow Setting
+ Messages you send will no longer disappear. Are you sure you want to turn off disappearing messages?
+ Set your messages to disappear %1$s after they have been %2$s?
+ Set
+ Confirm
Open
Copy URL
Enable Link Previews?
@@ -1023,6 +1047,21 @@
Join
Navigate Back
Close Dialog
+ Disappearing Messages
+ This setting applies to messages you send in this conversation.
+ Original version of disappearing messages.
+ Legacy
+ Messages disappear after they have been sent.
+ Disappear After Read
+ Messages delete after they have been read.
+ Disappear After Send
+ Messages delete after they have been sent.
+ Set
+ Delete Type
+ Timer
+ This setting applies to everyone in this conversation.\nOnly group admins can change this setting.
+ %s is using an outdated client. Disappearing messages may not work as expected.
+ Settings not updated and please try again
Database Upgrade Failed
Please contact support to report the error.
Syncing
@@ -1041,5 +1080,6 @@
You have no messages from %s.\nSend a message to start the conversation!
Unread Messages
+ Auto-deletes in %1$s
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 @@
- @color/emoji_tab_text_color
+
+
+
+