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 01ec4c41ce..be928b3933 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,4 +15,8 @@ signing.properties
ffpr
*.sh
pkcs11.password
-play
+app/play
+app/huawei
+
+!/scripts/drone-static-upload.sh
+!/scripts/drone-upload-exists.sh
\ No newline at end of file
diff --git a/.run/Run Tests.run.xml b/.run/Run Tests.run.xml
new file mode 100644
index 0000000000..42b2e07744
--- /dev/null
+++ b/.run/Run Tests.run.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+ false
+
+
+
\ No newline at end of file
diff --git a/BUILDING.md b/BUILDING.md
index f88509c680..48b4412ddd 100644
--- a/BUILDING.md
+++ b/BUILDING.md
@@ -34,6 +34,12 @@ Setting up a development environment and building from Android Studio
6. Project initialization and building should proceed.
7. Clone submodules with `git submodule update --init --recursive`
+If you would like to build the Huawei Flavor with Huawei HMS push notifications you will need to pass 'huawei' as a command line arg to include the required dependencies.
+
+e.g. `./gradlew assembleHuaweiDebug -Phuawei`
+
+If you are building in Android Studio then add `-Phuawei` to `Preferences > Build, Execution, Deployment > Gradle-Android Compiler > Command-line Options`
+
Contributing code
-----------------
diff --git a/README.md b/README.md
index 723d50c758..17eaebf5fe 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Session Android
+# Session Android
[Download on the Google Play Store](https://getsession.org/android)
diff --git a/app/build.gradle b/app/build.gradle
index 74b9f84f07..eb2c16e953 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -24,160 +24,15 @@ apply plugin: 'kotlin-android'
apply plugin: 'witness'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-parcelize'
-apply plugin: 'com.google.gms.google-services'
apply plugin: 'kotlinx-serialization'
apply plugin: 'dagger.hilt.android.plugin'
-
configurations.all {
exclude module: "commons-logging"
}
-dependencies {
-
- implementation("com.google.dagger:hilt-android:2.46.1")
- kapt("com.google.dagger:hilt-android-compiler:2.44")
-
- implementation "androidx.appcompat:appcompat:$appcompatVersion"
- implementation 'androidx.recyclerview:recyclerview:1.2.1'
- implementation "com.google.android.material:material:$materialVersion"
- implementation 'com.google.android:flexbox:2.0.1'
- implementation 'androidx.legacy:legacy-support-v13:1.0.0'
- implementation 'androidx.cardview:cardview:1.0.0'
- implementation "androidx.preference:preference-ktx:$preferenceVersion"
- implementation 'androidx.legacy:legacy-preference-v14:1.0.0'
- implementation 'androidx.gridlayout:gridlayout:1.0.0'
- implementation 'androidx.exifinterface:exifinterface:1.3.4'
- implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
- implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
- implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
- implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
- implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion"
- implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
- implementation "androidx.paging:paging-runtime-ktx:$pagingVersion"
- implementation 'androidx.activity:activity-ktx:1.5.1'
- implementation 'androidx.fragment:fragment-ktx:1.5.3'
- implementation "androidx.core:core-ktx:$coreVersion"
- implementation "androidx.work:work-runtime-ktx:2.7.1"
- implementation ("com.google.firebase:firebase-messaging:18.0.0") {
- exclude group: 'com.google.firebase', module: 'firebase-core'
- exclude group: 'com.google.firebase', module: 'firebase-analytics'
- exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
- }
- implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1'
- implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1'
- implementation 'org.conscrypt:conscrypt-android:2.0.0'
- implementation 'org.signal:aesgcmprovider:0.0.3'
- implementation 'org.webrtc:google-webrtc:1.0.32006'
- implementation "me.leolin:ShortcutBadger:1.1.16"
- implementation 'se.emilsjolander:stickylistheaders:2.7.0'
- implementation 'com.jpardogo.materialtabstrip:library:1.0.9'
- implementation 'org.apache.httpcomponents:httpclient-android:4.3.5'
- implementation 'commons-net:commons-net:3.7.2'
- implementation 'com.github.chrisbanes:PhotoView:2.1.3'
- implementation "com.github.bumptech.glide:glide:$glideVersion"
- annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion"
- kapt "com.github.bumptech.glide:compiler:$glideVersion"
- implementation 'com.makeramen:roundedimageview:2.1.0'
- implementation 'com.pnikosis:materialish-progress:1.5'
- implementation 'org.greenrobot:eventbus:3.0.0'
- implementation 'pl.tajchert:waitingdots:0.1.0'
- implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0'
- implementation 'com.melnykov:floatingactionbutton:1.3.0'
- implementation 'com.google.zxing:android-integration:3.1.0'
- implementation "com.google.dagger:hilt-android:$daggerVersion"
- kapt "com.google.dagger:hilt-compiler:$daggerVersion"
- implementation 'mobi.upod:time-duration-picker:1.1.3'
- implementation 'com.google.zxing:core:3.2.1'
- implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') {
- exclude group: 'com.android.support', module: 'support-annotations'
- }
- implementation ('cn.carbswang.android:NumberPickerView:1.0.9') {
- exclude group: 'com.android.support', module: 'appcompat-v7'
- }
- implementation ('com.tomergoldst.android:tooltips:1.0.6') {
- exclude group: 'com.android.support', module: 'appcompat-v7'
- }
- implementation ('com.klinkerapps:android-smsmms:4.0.1') {
- exclude group: 'com.squareup.okhttp', module: 'okhttp'
- exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
- }
- implementation 'com.annimon:stream:1.1.8'
- implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
- implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
- implementation 'androidx.sqlite:sqlite-ktx:2.3.1'
- implementation 'net.zetetic:sqlcipher-android:4.5.4@aar'
- implementation project(":libsignal")
- implementation project(":libsession")
- implementation project(":libsession-util")
- implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion"
- implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version"
- implementation project(":liblazysodium")
- implementation "net.java.dev.jna:jna:5.8.0@aar"
- implementation "com.google.protobuf:protobuf-java:$protobufVersion"
- implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"
- implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
- implementation 'app.cash.copper:copper-flow:1.0.0'
- implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
- implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
- implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
- implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
- implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0"
- implementation "com.github.tbruyelle:rxpermissions:0.10.2"
- implementation "com.github.ybq:Android-SpinKit:1.4.0"
- implementation "com.opencsv:opencsv:4.6"
- testImplementation "junit:junit:$junitVersion"
- testImplementation 'org.assertj:assertj-core:3.11.1'
- testImplementation "org.mockito:mockito-inline:4.10.0"
- testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
- androidTestImplementation "org.mockito:mockito-android:4.10.0"
- androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
- testImplementation "androidx.test:core:$testCoreVersion"
- testImplementation "androidx.arch.core:core-testing:2.2.0"
- testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
- androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
- // Core library
- androidTestImplementation "androidx.test:core:$testCoreVersion"
-
- androidTestImplementation('com.adevinta.android:barista:4.2.0') {
- exclude group: 'org.jetbrains.kotlin'
- }
-
- // AndroidJUnitRunner and JUnit Rules
- androidTestImplementation 'androidx.test:runner:1.5.2'
- androidTestImplementation 'androidx.test:rules:1.5.0'
-
- // Assertions
- androidTestImplementation 'androidx.test.ext:junit:1.1.5'
- androidTestImplementation 'androidx.test.ext:truth:1.5.0'
- androidTestImplementation 'com.google.truth:truth:1.1.3'
-
- // Espresso dependencies
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
- androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
- androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
- androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1'
- androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
- androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
- androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
- androidTestUtil 'androidx.test:orchestrator:1.4.2'
-
- testImplementation 'org.robolectric:robolectric:4.4'
- testImplementation 'org.robolectric:shadows-multidex:4.4'
-
- implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.1'
- implementation 'androidx.compose.ui:ui:1.4.3'
- implementation 'androidx.compose.ui:ui-tooling:1.4.3'
- implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.31.5-beta"
- implementation "com.google.accompanist:accompanist-pager-indicators:0.31.5-beta"
- implementation "androidx.compose.runtime:runtime-livedata:1.4.3"
-
- implementation 'androidx.compose.foundation:foundation-layout:1.5.0-alpha02'
- implementation 'androidx.compose.material:material:1.5.0-alpha02'
-}
-
-def canonicalVersionCode = 354
-def canonicalVersionName = "1.17.0"
+def canonicalVersionCode = 373
+def canonicalVersionName = "1.18.4"
def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1,
@@ -186,6 +41,17 @@ def abiPostFix = ['armeabi-v7a' : 1,
'x86_64' : 4,
'universal' : 5]
+// Function to get the current git commit hash so we can embed it along w/ the build version.
+// Note: This is visible in the SettingsActivity, right at the bottom (R.id.versionTextView).
+def getGitHash = { ->
+ def stdout = new ByteArrayOutputStream()
+ exec {
+ commandLine "git", "rev-parse", "--short", "HEAD"
+ standardOutput = stdout
+ }
+ return stdout.toString().trim()
+}
+
android {
compileSdkVersion androidCompileSdkVersion
namespace 'network.loki.messenger'
@@ -239,6 +105,7 @@ android {
project.ext.set("archivesBaseName", "session")
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
+ buildConfigField "String", "GIT_HASH", "\"$getGitHash\""
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
buildConfigField "String", "USER_AGENT", "\"OWA\""
@@ -267,22 +134,41 @@ 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"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
+ buildConfigField "org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.ANDROID"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
+ buildConfigField 'String', 'PUSH_KEY_SUFFIX', '\"\"'
+ }
+
+ huawei {
+ dimension "distribution"
+ ext.websiteUpdateUrl = "null"
+ buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
+ buildConfigField "org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.HUAWEI"
+ buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
+ buildConfigField 'String', 'PUSH_KEY_SUFFIX', '\"_HUAWEI\"'
}
website {
+ dimension "distribution"
ext.websiteUpdateUrl = "https://github.com/oxen-io/session-android/releases"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
+ buildConfigField "org.session.libsession.utilities.Device", "DEVICE", "org.session.libsession.utilities.Device.ANDROID"
buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\""
+ buildConfigField 'String', 'PUSH_KEY_SUFFIX', '\"\"'
}
}
@@ -312,6 +198,188 @@ android {
dataBinding true
viewBinding true
}
+
+ def huaweiEnabled = project.properties['huawei'] != null
+
+ applicationVariants.configureEach { variant ->
+ if (variant.flavorName == 'huawei') {
+ variant.getPreBuildProvider().configure { task ->
+ task.doFirst {
+ if (!huaweiEnabled) {
+ def message = 'Huawei is not enabled. Please add -Phuawei command line arg. See BUILDING.md'
+ logger.error(message)
+ throw new GradleException(message)
+ }
+ }
+ }
+ }
+ }
+
+ 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 {
+
+ implementation("com.google.dagger:hilt-android:2.46.1")
+ kapt("com.google.dagger:hilt-android-compiler:2.44")
+
+ implementation "androidx.appcompat:appcompat:$appcompatVersion"
+ implementation 'androidx.recyclerview:recyclerview:1.2.1'
+ implementation "com.google.android.material:material:$materialVersion"
+ implementation 'com.google.android:flexbox:2.0.1'
+ implementation 'androidx.legacy:legacy-support-v13:1.0.0'
+ implementation 'androidx.cardview:cardview:1.0.0'
+ implementation "androidx.preference:preference-ktx:$preferenceVersion"
+ implementation 'androidx.legacy:legacy-preference-v14:1.0.0'
+ implementation 'androidx.gridlayout:gridlayout:1.0.0'
+ implementation 'androidx.exifinterface:exifinterface:1.3.4'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
+ implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
+ implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
+ implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion"
+ implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
+ implementation "androidx.paging:paging-runtime-ktx:$pagingVersion"
+ implementation 'androidx.activity:activity-ktx:1.5.1'
+ implementation 'androidx.fragment:fragment-ktx:1.5.3'
+ implementation "androidx.core:core-ktx:$coreVersion"
+ implementation "androidx.work:work-runtime-ktx:2.7.1"
+ playImplementation ("com.google.firebase:firebase-messaging:18.0.0") {
+ exclude group: 'com.google.firebase', module: 'firebase-core'
+ exclude group: 'com.google.firebase', module: 'firebase-analytics'
+ exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
+ }
+ if (project.hasProperty('huawei')) huaweiImplementation 'com.huawei.hms:push:6.7.0.300'
+ implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1'
+ implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1'
+ implementation 'org.conscrypt:conscrypt-android:2.0.0'
+ implementation 'org.signal:aesgcmprovider:0.0.3'
+ implementation 'org.webrtc:google-webrtc:1.0.32006'
+ implementation "me.leolin:ShortcutBadger:1.1.16"
+ implementation 'se.emilsjolander:stickylistheaders:2.7.0'
+ implementation 'com.jpardogo.materialtabstrip:library:1.0.9'
+ implementation 'org.apache.httpcomponents:httpclient-android:4.3.5'
+ implementation 'commons-net:commons-net:3.7.2'
+ implementation 'com.github.chrisbanes:PhotoView:2.1.3'
+ implementation "com.github.bumptech.glide:glide:$glideVersion"
+ annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion"
+ kapt "com.github.bumptech.glide:compiler:$glideVersion"
+ implementation 'com.makeramen:roundedimageview:2.1.0'
+ implementation 'com.pnikosis:materialish-progress:1.5'
+ implementation 'org.greenrobot:eventbus:3.0.0'
+ implementation 'pl.tajchert:waitingdots:0.1.0'
+ implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0'
+ implementation 'com.melnykov:floatingactionbutton:1.3.0'
+ implementation 'com.google.zxing:android-integration:3.1.0'
+ implementation "com.google.dagger:hilt-android:$daggerVersion"
+ kapt "com.google.dagger:hilt-compiler:$daggerVersion"
+ implementation 'mobi.upod:time-duration-picker:1.1.3'
+ implementation 'com.google.zxing:core:3.2.1'
+ implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') {
+ exclude group: 'com.android.support', module: 'support-annotations'
+ }
+ implementation ('cn.carbswang.android:NumberPickerView:1.0.9') {
+ exclude group: 'com.android.support', module: 'appcompat-v7'
+ }
+ implementation ('com.tomergoldst.android:tooltips:1.0.6') {
+ exclude group: 'com.android.support', module: 'appcompat-v7'
+ }
+ implementation ('com.klinkerapps:android-smsmms:4.0.1') {
+ exclude group: 'com.squareup.okhttp', module: 'okhttp'
+ exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
+ }
+ implementation 'com.annimon:stream:1.1.8'
+ implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
+ implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
+ implementation 'androidx.sqlite:sqlite-ktx:2.3.1'
+ implementation 'net.zetetic:sqlcipher-android:4.5.4@aar'
+ implementation project(":libsignal")
+ implementation project(":libsession")
+ implementation project(":libsession-util")
+ implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion"
+ implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version"
+ implementation project(":liblazysodium")
+ 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"
+ implementation 'app.cash.copper:copper-flow:1.0.0'
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
+ implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
+ implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion"
+ implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0"
+ implementation "com.github.tbruyelle:rxpermissions:0.10.2"
+ implementation "com.github.ybq:Android-SpinKit:1.4.0"
+ implementation "com.opencsv:opencsv:4.6"
+ testImplementation "junit:junit:$junitVersion"
+ testImplementation 'org.assertj:assertj-core:3.11.1'
+ testImplementation "org.mockito:mockito-inline:4.11.0"
+ testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
+ 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"
+ testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
+ androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
+ // Core library
+ androidTestImplementation "androidx.test:core:$testCoreVersion"
+
+ androidTestImplementation('com.adevinta.android:barista:4.2.0') {
+ exclude group: 'org.jetbrains.kotlin'
+ }
+
+ // AndroidJUnitRunner and JUnit Rules
+ androidTestImplementation 'androidx.test:runner:1.5.2'
+ androidTestImplementation 'androidx.test:rules:1.5.0'
+
+ // 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
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
+ androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
+ androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
+ androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.5.1'
+ androidTestImplementation 'androidx.test.espresso:espresso-web:3.5.1'
+ androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.5.1'
+ androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.5.1'
+ androidTestUtil 'androidx.test:orchestrator:1.4.2'
+
+ testImplementation 'org.robolectric:robolectric:4.4'
+ testImplementation 'org.robolectric:shadows-multidex:4.4'
+
+ implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.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.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/huawei/AndroidManifest.xml b/app/src/huawei/AndroidManifest.xml
new file mode 100644
index 0000000000..dad7ab3ac6
--- /dev/null
+++ b/app/src/huawei/AndroidManifest.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/huawei/agconnect-services.json b/app/src/huawei/agconnect-services.json
new file mode 100644
index 0000000000..0c81d0477a
--- /dev/null
+++ b/app/src/huawei/agconnect-services.json
@@ -0,0 +1,96 @@
+{
+ "agcgw":{
+ "backurl":"connect-dre.hispace.hicloud.com",
+ "url":"connect-dre.dbankcloud.cn",
+ "websocketbackurl":"connect-ws-dre.hispace.dbankcloud.com",
+ "websocketurl":"connect-ws-dre.hispace.dbankcloud.cn"
+ },
+ "agcgw_all":{
+ "CN":"connect-drcn.dbankcloud.cn",
+ "CN_back":"connect-drcn.hispace.hicloud.com",
+ "DE":"connect-dre.dbankcloud.cn",
+ "DE_back":"connect-dre.hispace.hicloud.com",
+ "RU":"connect-drru.hispace.dbankcloud.ru",
+ "RU_back":"connect-drru.hispace.dbankcloud.cn",
+ "SG":"connect-dra.dbankcloud.cn",
+ "SG_back":"connect-dra.hispace.hicloud.com"
+ },
+ "websocketgw_all":{
+ "CN":"connect-ws-drcn.hispace.dbankcloud.cn",
+ "CN_back":"connect-ws-drcn.hispace.dbankcloud.com",
+ "DE":"connect-ws-dre.hispace.dbankcloud.cn",
+ "DE_back":"connect-ws-dre.hispace.dbankcloud.com",
+ "RU":"connect-ws-drru.hispace.dbankcloud.ru",
+ "RU_back":"connect-ws-drru.hispace.dbankcloud.cn",
+ "SG":"connect-ws-dra.hispace.dbankcloud.cn",
+ "SG_back":"connect-ws-dra.hispace.dbankcloud.com"
+ },
+ "client":{
+ "cp_id":"890061000023000573",
+ "product_id":"99536292102532562",
+ "client_id":"954244311350791232",
+ "client_secret":"555999202D718B6744DAD2E923B386DC17F3F4E29F5105CE0D061EED328DADEE",
+ "project_id":"99536292102532562",
+ "app_id":"107205081",
+ "api_key":"DAEDABeddLEqUy0QRwa1THLwRA0OqrSuyci/HjNvVSmsdWsXRM2U2hRaCyqfvGYH1IFOKrauArssz/WPMLRHCYxliWf+DTj9bDwlWA==",
+ "package_name":"network.loki.messenger"
+ },
+ "oauth_client":{
+ "client_id":"107205081",
+ "client_type":1
+ },
+ "app_info":{
+ "app_id":"107205081",
+ "package_name":"network.loki.messenger"
+ },
+ "service":{
+ "analytics":{
+ "collector_url":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn",
+ "collector_url_ru":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com",
+ "collector_url_sg":"datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn",
+ "collector_url_de":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn",
+ "collector_url_cn":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn",
+ "resource_id":"p1",
+ "channel_id":""
+ },
+ "edukit":{
+ "edu_url":"edukit.edu.cloud.huawei.com.cn",
+ "dh_url":"edukit.edu.cloud.huawei.com.cn"
+ },
+ "search":{
+ "url":"https://search-dre.cloud.huawei.com"
+ },
+ "cloudstorage":{
+ "storage_url_sg_back":"https://agc-storage-dra.cloud.huawei.asia",
+ "storage_url_ru_back":"https://agc-storage-drru.cloud.huawei.ru",
+ "storage_url_ru":"https://agc-storage-drru.cloud.huawei.ru",
+ "storage_url_de_back":"https://agc-storage-dre.cloud.huawei.eu",
+ "storage_url_de":"https://ops-dre.agcstorage.link",
+ "storage_url":"https://agc-storage-drcn.platform.dbankcloud.cn",
+ "storage_url_sg":"https://ops-dra.agcstorage.link",
+ "storage_url_cn_back":"https://agc-storage-drcn.cloud.huawei.com.cn",
+ "storage_url_cn":"https://agc-storage-drcn.platform.dbankcloud.cn"
+ },
+ "ml":{
+ "mlservice_url":"ml-api-dre.ai.dbankcloud.com,ml-api-dre.ai.dbankcloud.cn"
+ }
+ },
+ "region":"DE",
+ "configuration_version":"3.0",
+ "appInfos":[
+ {
+ "package_name":"network.loki.messenger",
+ "client":{
+ "app_id":"107205081"
+ },
+ "app_info":{
+ "package_name":"network.loki.messenger",
+ "app_id":"107205081"
+ },
+ "oauth_client":{
+ "client_type":1,
+ "client_id":"107205081"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushModule.kt b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushModule.kt
new file mode 100644
index 0000000000..26a484df16
--- /dev/null
+++ b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushModule.kt
@@ -0,0 +1,13 @@
+package org.thoughtcrime.securesms.notifications
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class HuaweiBindingModule {
+ @Binds
+ abstract fun bindTokenFetcher(tokenFetcher: HuaweiTokenFetcher): TokenFetcher
+}
diff --git a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt
new file mode 100644
index 0000000000..dc7bf893d7
--- /dev/null
+++ b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt
@@ -0,0 +1,40 @@
+package org.thoughtcrime.securesms.notifications
+
+import android.os.Bundle
+import com.huawei.hms.push.HmsMessageService
+import com.huawei.hms.push.RemoteMessage
+import dagger.hilt.android.AndroidEntryPoint
+import org.json.JSONException
+import org.session.libsession.utilities.TextSecurePreferences
+import org.session.libsignal.utilities.Base64
+import org.session.libsignal.utilities.Log
+import java.lang.Exception
+import javax.inject.Inject
+
+private val TAG = HuaweiPushService::class.java.simpleName
+
+@AndroidEntryPoint
+class HuaweiPushService: HmsMessageService() {
+ @Inject lateinit var pushRegistry: PushRegistry
+ @Inject lateinit var pushReceiver: PushReceiver
+
+ override fun onMessageReceived(message: RemoteMessage?) {
+ Log.d(TAG, "onMessageReceived")
+ message?.dataOfMap?.takeIf { it.isNotEmpty() }?.let(pushReceiver::onPush) ?:
+ pushReceiver.onPush(message?.data?.let(Base64::decode))
+ }
+
+ override fun onNewToken(token: String?) {
+ pushRegistry.register(token)
+ }
+
+ override fun onNewToken(token: String?, bundle: Bundle?) {
+ Log.d(TAG, "New HCM token: $token.")
+ pushRegistry.register(token)
+ }
+
+ override fun onDeletedMessages() {
+ Log.d(TAG, "onDeletedMessages")
+ pushRegistry.refresh(false)
+ }
+}
diff --git a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt
new file mode 100644
index 0000000000..9d9b61ce9a
--- /dev/null
+++ b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt
@@ -0,0 +1,29 @@
+package org.thoughtcrime.securesms.notifications
+
+import android.content.Context
+import com.huawei.hms.aaid.HmsInstanceId
+import dagger.Lazy
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.session.libsignal.utilities.Log
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val APP_ID = "107205081"
+private const val TOKEN_SCOPE = "HCM"
+
+@Singleton
+class HuaweiTokenFetcher @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val pushRegistry: Lazy,
+): TokenFetcher {
+ override suspend fun fetch(): String? = HmsInstanceId.getInstance(context).run {
+ // https://developer.huawei.com/consumer/en/doc/development/HMS-Guides/push-basic-capability#h2-1576218800370
+ // getToken may return an empty string, if so HuaweiPushService#onNewToken will be called.
+ withContext(Dispatchers.IO) { getToken(APP_ID, TOKEN_SCOPE) }
+ }
+}
diff --git a/app/src/huawei/res/values/strings.xml b/app/src/huawei/res/values/strings.xml
new file mode 100644
index 0000000000..78d42b3e30
--- /dev/null
+++ b/app/src/huawei/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+
+ You\'ll be notified of new messages reliably and immediately using Huaweiâs notification servers.
+ You\'ll be notified of new messages reliably and immediately using Huaweiâs notification servers.
+
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index aa81fafc2b..79d55b37f8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -34,11 +34,14 @@
+
+
+
@@ -104,11 +107,6 @@
android:name="org.thoughtcrime.securesms.onboarding.RegisterActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
-
+
+ android:theme="@style/Theme.Session.DayNight.NoActionBar"
+ android:windowSoftInputMode="adjustResize" >
+
-
-
-
-
-
-
+ android:exported="false" android:foregroundServiceType="specialUse">
+
+
+
Log.d("Loki", "Promise resolved more than once (first with $v1, then with $v2); ignoring $v2.")
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
index e4be27f24b..03b56d6b61 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
@@ -41,6 +41,7 @@ import org.session.libsession.messaging.sending_receiving.pollers.Poller;
import org.session.libsession.snode.SnodeModule;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.ConfigFactoryUpdateListener;
+import org.session.libsession.utilities.Device;
import org.session.libsession.utilities.ProfilePictureUtilities;
import org.session.libsession.utilities.SSKEnvironment;
import org.session.libsession.utilities.TextSecurePreferences;
@@ -56,6 +57,7 @@ import org.signal.aesgcmprovider.AesGcmProvider;
import org.thoughtcrime.securesms.components.TypingStatusSender;
import org.thoughtcrime.securesms.crypto.KeyPairUtilities;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
+import org.thoughtcrime.securesms.database.LastSentTimestampCache;
import org.thoughtcrime.securesms.database.LokiAPIDatabase;
import org.thoughtcrime.securesms.database.Storage;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
@@ -73,10 +75,9 @@ import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.logging.UncaughtExceptionLogger;
import org.thoughtcrime.securesms.notifications.BackgroundPollWorker;
import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier;
-import org.thoughtcrime.securesms.notifications.FcmUtils;
-import org.thoughtcrime.securesms.notifications.LokiPushNotificationManager;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier;
+import org.thoughtcrime.securesms.notifications.PushRegistry;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.service.KeyCachingService;
@@ -109,6 +110,7 @@ import dagger.hilt.EntryPoints;
import dagger.hilt.android.HiltAndroidApp;
import kotlin.Unit;
import kotlinx.coroutines.Job;
+import network.loki.messenger.BuildConfig;
import network.loki.messenger.libsession_util.ConfigBase;
import network.loki.messenger.libsession_util.UserProfile;
@@ -143,9 +145,12 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
@Inject LokiAPIDatabase lokiAPIDatabase;
@Inject public Storage storage;
+ @Inject Device device;
@Inject MessageDataProvider messageDataProvider;
@Inject TextSecurePreferences textSecurePreferences;
+ @Inject PushRegistry pushRegistry;
@Inject ConfigFactory configFactory;
+ @Inject LastSentTimestampCache lastSentTimestampCache;
CallMessageProcessor callMessageProcessor;
MessagingModuleConfiguration messagingModuleConfiguration;
@@ -194,24 +199,29 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}
@Override
- public void notifyUpdates(@NonNull ConfigBase forConfigObject) {
+ public void notifyUpdates(@NonNull ConfigBase forConfigObject, long messageTimestamp) {
// forward to the config factory / storage ig
if (forConfigObject instanceof UserProfile && !textSecurePreferences.getConfigurationMessageSynced()) {
textSecurePreferences.setConfigurationMessageSynced(true);
}
- storage.notifyConfigUpdates(forConfigObject);
+ storage.notifyConfigUpdates(forConfigObject, messageTimestamp);
}
@Override
public void onCreate() {
+ TextSecurePreferences.setPushSuffix(BuildConfig.PUSH_KEY_SUFFIX);
+
DatabaseModule.init(this);
MessagingModuleConfiguration.configure(this);
super.onCreate();
- messagingModuleConfiguration = new MessagingModuleConfiguration(this,
+ messagingModuleConfiguration = new MessagingModuleConfiguration(
+ this,
storage,
+ device,
messageDataProvider,
()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this),
- configFactory
+ configFactory,
+ lastSentTimestampCache
);
callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage);
Log.i(TAG, "onCreate()");
@@ -226,10 +236,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
broadcaster = new Broadcaster(this);
LokiAPIDatabase apiDB = getDatabaseComponent().lokiAPIDatabase();
SnodeModule.Companion.configure(apiDB, broadcaster);
- String userPublicKey = TextSecurePreferences.getLocalNumber(this);
- if (userPublicKey != null) {
- registerForFCMIfNeeded(false);
- }
initializeExpiringMessageManager();
initializeTypingStatusRepository();
initializeTypingStatusSender();
@@ -427,33 +433,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}
private static class ProviderInitializationException extends RuntimeException { }
-
- public void registerForFCMIfNeeded(final Boolean force) {
- if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive() && !force) return;
- if (force && firebaseInstanceIdJob != null) {
- firebaseInstanceIdJob.cancel(null);
- }
- firebaseInstanceIdJob = FcmUtils.getFcmInstanceId(task->{
- if (!task.isSuccessful()) {
- Log.w("Loki", "FirebaseInstanceId.getInstance().getInstanceId() failed." + task.getException());
- return Unit.INSTANCE;
- }
- String token = task.getResult().getToken();
- String userPublicKey = TextSecurePreferences.getLocalNumber(this);
- if (userPublicKey == null) return Unit.INSTANCE;
-
- AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
- if (TextSecurePreferences.isUsingFCM(this)) {
- LokiPushNotificationManager.register(token, userPublicKey, this, force);
- } else {
- LokiPushNotificationManager.unregister(token, this);
- }
- });
-
- return Unit.INSTANCE;
- });
- }
-
private void setUpPollingIfNeeded() {
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
if (userPublicKey == null) return;
@@ -502,9 +481,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
Log.d("Loki-Avatar", "Uploading Avatar Finished");
return Unit.INSTANCE;
});
- } catch (Exception exception) {
- // Do nothing
- Log.e("Loki-Avatar", "Uploading avatar failed", exception);
+ } catch (Exception e) {
+ Log.e("Loki-Avatar", "Uploading avatar failed.");
}
});
}
@@ -524,18 +502,14 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}
public void clearAllData(boolean isMigratingToV2KeyPair) {
- String token = TextSecurePreferences.getFCMToken(this);
- if (token != null && !token.isEmpty()) {
- LokiPushNotificationManager.unregister(token, this);
- }
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) {
firebaseInstanceIdJob.cancel(null);
}
String displayName = TextSecurePreferences.getProfileName(this);
- boolean isUsingFCM = TextSecurePreferences.isUsingFCM(this);
+ boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this);
TextSecurePreferences.clearAll(this);
if (isMigratingToV2KeyPair) {
- TextSecurePreferences.setIsUsingFCM(this, isUsingFCM);
+ TextSecurePreferences.setPushEnabled(this, isUsingFCM);
TextSecurePreferences.setProfileName(this, displayName);
}
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java
index 51f66ec323..a99fe83430 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java
@@ -30,30 +30,37 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
private static final String TAG = BaseActionBarActivity.class.getSimpleName();
public ThemeState currentThemeState;
+ private Resources.Theme modifiedTheme;
+
private TextSecurePreferences getPreferences() {
ApplicationContext appContext = (ApplicationContext) getApplicationContext();
return appContext.textSecurePreferences;
}
@StyleRes
- public int getDesiredTheme() {
+ private int getDesiredTheme() {
ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences());
int userSelectedTheme = themeState.getTheme();
+
+ // If the user has configured Session to follow the system light/dark theme mode then do so..
if (themeState.getFollowSystem()) {
- // do light or dark based on the selected theme
+
+ // Use light or dark versions of the user's theme based on light-mode / dark-mode settings
boolean isDayUi = UiModeUtilities.isDayUiMode(this);
if (userSelectedTheme == R.style.Ocean_Dark || userSelectedTheme == R.style.Ocean_Light) {
return isDayUi ? R.style.Ocean_Light : R.style.Ocean_Dark;
} else {
return isDayUi ? R.style.Classic_Light : R.style.Classic_Dark;
}
- } else {
+ }
+ else // ..otherwise just return their selected theme.
+ {
return userSelectedTheme;
}
}
@StyleRes @Nullable
- public Integer getAccentTheme() {
+ private Integer getAccentTheme() {
if (!getPreferences().hasPreference(SELECTED_ACCENT_COLOR)) return null;
ThemeState themeState = ActivityUtilitiesKt.themeState(getPreferences());
return themeState.getAccentStyle();
@@ -61,8 +68,12 @@ public abstract class BaseActionBarActivity extends AppCompatActivity {
@Override
public Resources.Theme getTheme() {
+ if (modifiedTheme != null) {
+ return modifiedTheme;
+ }
+
// New themes
- Resources.Theme modifiedTheme = super.getTheme();
+ modifiedTheme = super.getTheme();
modifiedTheme.applyStyle(getDesiredTheme(), true);
Integer accentTheme = getAccentTheme();
if (accentTheme != null) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java
deleted file mode 100644
index 93313e5270..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java
+++ /dev/null
@@ -1,39 +0,0 @@
-package org.thoughtcrime.securesms;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-
-import org.thoughtcrime.securesms.database.model.MessageRecord;
-import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
-import org.thoughtcrime.securesms.mms.GlideRequests;
-import org.session.libsignal.utilities.guava.Optional;
-
-import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview;
-import org.session.libsession.utilities.Address;
-import org.session.libsession.utilities.recipients.Recipient;
-
-import java.util.Locale;
-import java.util.Set;
-
-public interface BindableConversationItem extends Unbindable {
- void bind(@NonNull MessageRecord messageRecord,
- @NonNull Optional previousMessageRecord,
- @NonNull Optional nextMessageRecord,
- @NonNull GlideRequests glideRequests,
- @NonNull Locale locale,
- @NonNull Set batchSelected,
- @NonNull Recipient recipients,
- @Nullable String searchQuery,
- boolean pulseHighlight);
-
- MessageRecord getMessageRecord();
-
- void setEventListener(@Nullable EventListener listener);
-
- interface EventListener {
- void onQuoteClicked(MmsMessageRecord messageRecord);
- void onLinkPreviewClicked(@NonNull LinkPreview linkPreview);
- void onMoreTextClicked(@NonNull Address conversationAddress, long messageId, boolean isMms);
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceModule.kt b/app/src/main/java/org/thoughtcrime/securesms/DeviceModule.kt
new file mode 100644
index 0000000000..bdfa9b6088
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceModule.kt
@@ -0,0 +1,16 @@
+package org.thoughtcrime.securesms
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import network.loki.messenger.BuildConfig
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DeviceModule {
+ @Provides
+ @Singleton
+ fun provides() = BuildConfig.DEVICE
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.kt
deleted file mode 100644
index 9a34c1ec4b..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-package org.thoughtcrime.securesms
-
-import android.content.Context
-import android.view.LayoutInflater
-import android.widget.TextView
-import androidx.appcompat.app.AlertDialog
-import cn.carbswang.android.numberpickerview.library.NumberPickerView
-import network.loki.messenger.R
-import org.session.libsession.utilities.ExpirationUtil
-
-fun Context.showExpirationDialog(
- expiration: Int,
- onExpirationTime: (Int) -> Unit
-): AlertDialog {
- val view = LayoutInflater.from(this).inflate(R.layout.expiration_dialog, null)
- val numberPickerView = view.findViewById(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..2e67becbfd 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java
@@ -21,6 +21,7 @@ import android.annotation.TargetApi;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
+import android.database.CursorIndexOutOfBoundsException;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
@@ -47,7 +48,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;
@@ -146,6 +146,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION);
}
};
+ private MediaItemAdapter adapter;
public static Intent getPreviewIntent(Context context, MediaPreviewArgs args) {
return getPreviewIntent(context, args.getSlide(), args.getMmsRecord(), args.getThread());
@@ -218,13 +219,6 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
}
- @TargetApi(VERSION_CODES.JELLY_BEAN)
- private void setFullscreenIfPossible() {
- if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) {
- getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_FULLSCREEN);
- }
- }
-
@Override
public void onModified(Recipient recipient) {
Util.runOnMain(this::updateActionBar);
@@ -286,9 +280,6 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
mediaPager = findViewById(R.id.media_pager);
mediaPager.setOffscreenPageLimit(1);
- viewPagerListener = new ViewPagerListener();
- mediaPager.addOnPageChangeListener(viewPagerListener);
-
albumRail = findViewById(R.id.media_preview_album_rail);
albumRailAdapter = new MediaRailAdapter(GlideApp.with(this), this, false);
@@ -379,7 +370,8 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
if (conversationRecipient != null) {
getSupportLoaderManager().restartLoader(0, null, this);
} else {
- mediaPager.setAdapter(new SingleItemPagerAdapter(this, GlideApp.with(this), getWindow(), initialMediaUri, initialMediaType, initialMediaSize));
+ adapter = new SingleItemPagerAdapter(this, GlideApp.with(this), getWindow(), initialMediaUri, initialMediaType, initialMediaSize);
+ mediaPager.setAdapter(adapter);
if (initialCaption != null) {
detailsContainer.setVisibility(View.VISIBLE);
@@ -507,13 +499,8 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
}
private @Nullable MediaItem getCurrentMediaItem() {
- MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter();
-
- if (adapter != null) {
- return adapter.getMediaItemFor(mediaPager.getCurrentItem());
- } else {
- return null;
- }
+ if (adapter == null) return null;
+ return adapter.getMediaItemFor(mediaPager.getCurrentItem());
}
public static boolean isContentTypeSupported(final String contentType) {
@@ -527,19 +514,28 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
@Override
public void onLoadFinished(@NonNull Loader> loader, @Nullable Pair data) {
- if (data != null) {
- CursorPagerAdapter adapter = new CursorPagerAdapter(this, GlideApp.with(this), getWindow(), data.first, data.second, leftIsRecent);
- mediaPager.setAdapter(adapter);
- adapter.setActive(true);
+ if (data == null) return;
- viewModel.setCursor(this, data.first, leftIsRecent);
+ mediaPager.removeOnPageChangeListener(viewPagerListener);
- int item = restartItem >= 0 ? restartItem : data.second;
+ adapter = new CursorPagerAdapter(this, GlideApp.with(this), getWindow(), data.first, data.second, leftIsRecent);
+ mediaPager.setAdapter(adapter);
+
+ viewModel.setCursor(this, data.first, leftIsRecent);
+
+ int item = restartItem >= 0 && restartItem < adapter.getCount() ? restartItem : Math.max(Math.min(data.second, adapter.getCount() - 1), 0);
+
+ viewPagerListener = new ViewPagerListener();
+ mediaPager.addOnPageChangeListener(viewPagerListener);
+
+ try {
mediaPager.setCurrentItem(item);
+ } catch (CursorIndexOutOfBoundsException e) {
+ throw new RuntimeException("restartItem = " + restartItem + ", data.second = " + data.second + " leftIsRecent = " + leftIsRecent, e);
+ }
- if (item == 0) {
- viewPagerListener.onPageSelected(0);
- }
+ if (item == 0) {
+ viewPagerListener.onPageSelected(0);
}
}
@@ -557,26 +553,26 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
if (currentPage != -1 && currentPage != position) onPageUnselected(currentPage);
currentPage = position;
- MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter();
+ if (adapter == null) return;
- if (adapter != null) {
- MediaItem item = adapter.getMediaItemFor(position);
- if (item.recipient != null) item.recipient.addListener(MediaPreviewActivity.this);
- viewModel.setActiveAlbumRailItem(MediaPreviewActivity.this, position);
- updateActionBar();
- }
+ MediaItem item = adapter.getMediaItemFor(position);
+ if (item.recipient != null) item.recipient.addListener(MediaPreviewActivity.this);
+ viewModel.setActiveAlbumRailItem(MediaPreviewActivity.this, position);
+ updateActionBar();
}
public void onPageUnselected(int position) {
- MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter();
+ if (adapter == null) return;
- if (adapter != null) {
+ try {
MediaItem item = adapter.getMediaItemFor(position);
if (item.recipient != null) item.recipient.removeListener(MediaPreviewActivity.this);
-
- adapter.pause(position);
+ } catch (CursorIndexOutOfBoundsException e) {
+ throw new RuntimeException("position = " + position + " leftIsRecent = " + leftIsRecent, e);
}
+
+ adapter.pause(position);
}
@Override
@@ -590,7 +586,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
}
}
- private static class SingleItemPagerAdapter extends PagerAdapter implements MediaItemAdapter {
+ private static class SingleItemPagerAdapter extends MediaItemAdapter {
private final GlideRequests glideRequests;
private final Window window;
@@ -662,7 +658,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
}
}
- private static class CursorPagerAdapter extends PagerAdapter implements MediaItemAdapter {
+ private static class CursorPagerAdapter extends MediaItemAdapter {
private final WeakHashMap mediaViews = new WeakHashMap<>();
@@ -672,7 +668,6 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
private final Cursor cursor;
private final boolean leftIsRecent;
- private boolean active;
private int autoPlayPosition;
CursorPagerAdapter(@NonNull Context context, @NonNull GlideRequests glideRequests,
@@ -687,15 +682,9 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
this.leftIsRecent = leftIsRecent;
}
- public void setActive(boolean active) {
- this.active = active;
- notifyDataSetChanged();
- }
-
@Override
public int getCount() {
- if (!active) return 0;
- else return cursor.getCount();
+ return cursor.getCount();
}
@Override
@@ -768,8 +757,8 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
}
private int getCursorPosition(int position) {
- if (leftIsRecent) return position;
- else return cursor.getCount() - 1 - position;
+ int unclamped = leftIsRecent ? position : cursor.getCount() - 1 - position;
+ return Math.max(Math.min(unclamped, cursor.getCount() - 1), 0);
}
}
@@ -797,9 +786,9 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im
}
}
- interface MediaItemAdapter {
- MediaItem getMediaItemFor(int position);
- void pause(int position);
- @Nullable View getPlaybackControls(int position);
+ abstract static class MediaItemAdapter extends PagerAdapter {
+ abstract MediaItem getMediaItemFor(int position);
+ abstract void pause(int position);
+ @Nullable abstract View getPlaybackControls(int position);
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/MessageDetailsRecipientAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/MessageDetailsRecipientAdapter.java
deleted file mode 100644
index ca6cf8f6c8..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/MessageDetailsRecipientAdapter.java
+++ /dev/null
@@ -1,111 +0,0 @@
-package org.thoughtcrime.securesms;
-
-import android.content.Context;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.AbsListView;
-import android.widget.BaseAdapter;
-
-import androidx.annotation.NonNull;
-
-
-import org.thoughtcrime.securesms.database.model.MessageRecord;
-import org.thoughtcrime.securesms.contacts.UserView;
-import org.thoughtcrime.securesms.mms.GlideRequests;
-import org.session.libsession.utilities.recipients.Recipient;
-import org.session.libsession.utilities.Conversions;
-
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.util.List;
-
-class MessageDetailsRecipientAdapter extends BaseAdapter implements AbsListView.RecyclerListener {
-
- private final Context context;
- private final GlideRequests glideRequests;
- private final MessageRecord record;
- private final List members;
- private final boolean isPushGroup;
-
- MessageDetailsRecipientAdapter(@NonNull Context context, @NonNull GlideRequests glideRequests,
- @NonNull MessageRecord record, @NonNull List members,
- boolean isPushGroup)
- {
- this.context = context;
- this.glideRequests = glideRequests;
- this.record = record;
- this.isPushGroup = isPushGroup;
- this.members = members;
- }
-
- @Override
- public int getCount() {
- return members.size();
- }
-
- @Override
- public Object getItem(int position) {
- return members.get(position);
- }
-
- @Override
- public long getItemId(int position) {
- try {
- return Conversions.byteArrayToLong(MessageDigest.getInstance("SHA1").digest(members.get(position).recipient.getAddress().serialize().getBytes()));
- } catch (NoSuchAlgorithmException e) {
- throw new AssertionError(e);
- }
- }
-
- @Override
- public View getView(int position, View convertView, ViewGroup parent) {
- UserView result = new UserView(context);
- Recipient recipient = members.get(position).getRecipient();
- result.setOpenGroupThreadID(record.getThreadId());
- result.bind(recipient, glideRequests, UserView.ActionIndicator.None, false);
- return result;
- }
-
- @Override
- public void onMovedToScrapHeap(View view) {
- ((UserView)view).unbind();
- }
-
-
- static class RecipientDeliveryStatus {
-
- enum Status {
- UNKNOWN, PENDING, SENT, DELIVERED, READ
- }
-
- private final Recipient recipient;
- private final Status deliveryStatus;
- private final boolean isUnidentified;
- private final long timestamp;
-
- RecipientDeliveryStatus(Recipient recipient, Status deliveryStatus, boolean isUnidentified, long timestamp) {
- this.recipient = recipient;
- this.deliveryStatus = deliveryStatus;
- this.isUnidentified = isUnidentified;
- this.timestamp = timestamp;
- }
-
- Status getDeliveryStatus() {
- return deliveryStatus;
- }
-
- boolean isUnidentified() {
- return isUnidentified;
- }
-
- public long getTimestamp() {
- return timestamp;
- }
-
- public Recipient getRecipient() {
- return recipient;
- }
-
- }
-
-}
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 3fb5e2787c..598977392b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt
@@ -8,6 +8,7 @@ import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.Button
import android.widget.LinearLayout
import android.widget.LinearLayout.VERTICAL
+import android.widget.Space
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.annotation.LayoutRes
@@ -15,13 +16,11 @@ import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import androidx.appcompat.app.AlertDialog
import androidx.core.view.setMargins
-import androidx.core.view.setPadding
import androidx.core.view.updateMargins
import androidx.fragment.app.Fragment
import network.loki.messenger.R
import org.thoughtcrime.securesms.util.toPx
-
@DslMarker
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
annotation class DialogDsl
@@ -31,13 +30,16 @@ class SessionDialogBuilder(val context: Context) {
private val dp20 = toPx(20, context.resources)
private val dp40 = toPx(40, context.resources)
+ private val dp60 = toPx(60, context.resources)
private val dialogBuilder: AlertDialog.Builder = AlertDialog.Builder(context)
private var dialog: AlertDialog? = null
private fun dismiss() = dialog?.dismiss()
- private val topView = LinearLayout(context).apply { orientation = VERTICAL }
+ private val topView = LinearLayout(context)
+ .apply { setPadding(0, dp20, 0, 0) }
+ .apply { orientation = VERTICAL }
.also(dialogBuilder::setCustomTitle)
private val contentView = LinearLayout(context).apply { orientation = VERTICAL }
private val buttonLayout = LinearLayout(context)
@@ -53,18 +55,17 @@ class SessionDialogBuilder(val context: Context) {
fun title(text: CharSequence?) = title(text?.toString())
fun title(text: String?) {
- text(text, R.style.TextAppearance_AppCompat_Title) { setPadding(dp20) }
+ text(text, R.style.TextAppearance_AppCompat_Title) { setPadding(dp20, 0, dp20, 0) }
}
fun text(@StringRes id: Int, style: Int = 0) = text(context.getString(id), style)
fun text(text: CharSequence?, @StyleRes style: Int = 0) {
text(text, style) {
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
- .apply { updateMargins(dp40, 0, dp40, dp20) }
+ .apply { updateMargins(dp40, 0, dp40, 0) }
}
}
-
private fun text(text: CharSequence?, @StyleRes style: Int, modify: TextView.() -> Unit) {
text ?: return
TextView(context, null, 0, style)
@@ -73,6 +74,10 @@ class SessionDialogBuilder(val context: Context) {
textAlignment = View.TEXT_ALIGNMENT_CENTER
modify()
}.let(topView::addView)
+
+ Space(context).apply {
+ layoutParams = LinearLayout.LayoutParams(0, dp20)
+ }.let(topView::addView)
}
fun view(view: View) = contentView.addView(view)
@@ -105,7 +110,7 @@ class SessionDialogBuilder(val context: Context) {
fun destructiveButton(
@StringRes text: Int,
- @StringRes contentDescription: Int,
+ @StringRes contentDescription: Int = text,
listener: () -> Unit = {}
) = button(
text,
@@ -120,13 +125,12 @@ class SessionDialogBuilder(val context: Context) {
@StringRes text: Int,
@StringRes contentDescriptionRes: Int = text,
@StyleRes style: Int = R.style.Widget_Session_Button_Dialog_UnimportantText,
- dismiss: Boolean = false,
+ dismiss: Boolean = true,
listener: (() -> Unit) = {}
) = Button(context, null, 0, style).apply {
setText(text)
contentDescription = resources.getString(contentDescriptionRes)
- layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, 1f)
- .apply { setMargins(toPx(20, resources)) }
+ layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, dp60, 1f)
setOnClickListener {
listener.invoke()
if (dismiss) dismiss()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/Unbindable.java b/app/src/main/java/org/thoughtcrime/securesms/Unbindable.java
deleted file mode 100644
index 3dd5cd8cc0..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/Unbindable.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package org.thoughtcrime.securesms;
-
-public interface Unbindable {
- public void unbind();
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java
index e186007ee3..176a8c290f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentServer.java
@@ -50,7 +50,7 @@ public class AttachmentServer implements Runnable {
throws IOException
{
try {
- this.context = context;
+ this.context = context.getApplicationContext();
this.attachment = attachment;
this.socket = new ServerSocket(0, 0, InetAddress.getByAddress(new byte[]{127, 0, 0, 1}));
this.port = socket.getLocalPort();
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..6445abed3b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt
@@ -5,6 +5,8 @@ import android.text.TextUtils
import com.google.protobuf.ByteString
import org.greenrobot.eventbus.EventBus
import org.session.libsession.database.MessageDataProvider
+import org.session.libsession.messaging.MessagingModuleConfiguration
+import org.session.libsession.messaging.messages.control.UnsendRequest
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState
@@ -184,18 +186,33 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper)
override fun deleteMessage(messageID: Long, isSms: Boolean) {
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
else DatabaseComponent.get(context).mmsDatabase()
+ val (threadId, timestamp) = runCatching { messagingDatabase.getMessageRecord(messageID).run { threadId to timestamp } }.getOrNull() ?: (null to null)
+
messagingDatabase.deleteMessage(messageID)
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessage(messageID, isSms)
- DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID)
+ DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHash(messageID, mms = !isSms)
+
+ threadId ?: return
+ timestamp ?: return
+ MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(threadId, timestamp)
}
override fun deleteMessages(messageIDs: List, threadId: Long, isSms: Boolean) {
+
val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase()
else DatabaseComponent.get(context).mmsDatabase()
+ val messages = messageIDs.mapNotNull { runCatching { messagingDatabase.getMessageRecord(it) }.getOrNull() }
+
+ // Perform local delete
messagingDatabase.deleteMessages(messageIDs.toLongArray(), threadId)
+
+ // Perform online delete
DatabaseComponent.get(context).lokiMessageDatabase().deleteMessages(messageIDs)
- DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs)
+ DatabaseComponent.get(context).lokiMessageDatabase().deleteMessageServerHashes(messageIDs, mms = !isSms)
+
+ val threadId = messages.firstOrNull()?.threadId
+ threadId?.let{ MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(it, messages.map { it.timestamp }) }
}
override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? {
@@ -212,15 +229,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/audio/AudioRecorder.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java
index 35cbf16b63..fd265337f9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java
@@ -45,7 +45,8 @@ public class AudioRecorder {
Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId());
try {
if (audioCodec != null) {
- throw new AssertionError("We can only record once at a time.");
+ Log.e(TAG, "Trying to start recording while another recording is in progress, exiting...");
+ return;
}
ParcelFileDescriptor fds[] = ParcelFileDescriptor.createPipe();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java
index 61a92105aa..ef404bb070 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioSlidePlayer.java
@@ -80,6 +80,11 @@ public class AudioSlidePlayer implements SensorEventListener {
}
}
+ @Nullable
+ public synchronized static AudioSlidePlayer getInstance() {
+ return playing.orNull();
+ }
+
private AudioSlidePlayer(@NonNull Context context,
@NonNull AudioSlide slide,
@NonNull Listener listener)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt
index b87eac12c4..afa6944645 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt
@@ -93,6 +93,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
super.onNewIntent(intent)
if (intent?.action == ACTION_ANSWER) {
val answerIntent = WebRtcCallService.acceptCallIntent(this)
+ answerIntent.flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
ContextCompat.startForegroundService(this, answerIntent)
}
}
@@ -106,6 +107,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
setShowWhenLocked(true)
setTurnScreenOn(true)
}
+
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
@@ -334,6 +336,10 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
if (isEnabled) {
viewModel.localRenderer?.let { surfaceView ->
surfaceView.setZOrderOnTop(true)
+
+ // Mirror the video preview of the person making the call to prevent disorienting them
+ surfaceView.setMirror(true)
+
binding.localRenderer.addView(surfaceView)
}
}
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/Outliner.java b/app/src/main/java/org/thoughtcrime/securesms/components/Outliner.java
deleted file mode 100644
index cb6cfc7abf..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/components/Outliner.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package org.thoughtcrime.securesms.components;
-
-import android.graphics.Canvas;
-import android.graphics.Paint;
-import android.graphics.Path;
-import android.graphics.RectF;
-
-import androidx.annotation.ColorInt;
-
-public class Outliner {
-
- private final float[] radii = new float[8];
- private final Path corners = new Path();
- private final RectF bounds = new RectF();
- private final Paint outlinePaint = new Paint();
- {
- outlinePaint.setStyle(Paint.Style.STROKE);
- outlinePaint.setStrokeWidth(1f);
- outlinePaint.setAntiAlias(true);
- }
-
- public void setColor(@ColorInt int color) {
- outlinePaint.setColor(color);
- }
-
- public void draw(Canvas canvas) {
- final float halfStrokeWidth = outlinePaint.getStrokeWidth() / 2;
-
- bounds.left = halfStrokeWidth;
- bounds.top = halfStrokeWidth;
- bounds.right = canvas.getWidth() - halfStrokeWidth;
- bounds.bottom = canvas.getHeight() - halfStrokeWidth;
-
- corners.reset();
- corners.addRoundRect(bounds, radii, Path.Direction.CW);
-
- canvas.drawPath(corners, outlinePaint);
- }
-
- public void setRadius(int radius) {
- setRadii(radius, radius, radius, radius);
- }
-
- public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) {
- radii[0] = radii[1] = topLeft;
- radii[2] = radii[3] = topRight;
- radii[4] = radii[5] = bottomRight;
- radii[6] = radii[7] = bottomLeft;
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt
index 6044224601..52e2d52ab1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt
@@ -6,11 +6,9 @@ import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.RelativeLayout
-import androidx.annotation.DimenRes
import com.bumptech.glide.load.engine.DiskCacheStrategy
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewProfilePictureBinding
-import network.loki.messenger.databinding.ViewUserBinding
import org.session.libsession.avatars.ContactColors
import org.session.libsession.avatars.PlaceholderAvatarPhoto
import org.session.libsession.avatars.ProfileContactPhoto
@@ -34,13 +32,12 @@ class ProfilePictureView @JvmOverloads constructor(
var additionalDisplayName: String? = null
var isLarge = false
- private val profilePicturesCache = mutableMapOf()
+ private val profilePicturesCache = mutableMapOf()
private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
-
// endregion
constructor(context: Context, sender: Recipient): this(context) {
@@ -74,7 +71,7 @@ class ProfilePictureView @JvmOverloads constructor(
additionalDisplayName = getUserDisplayName(apk)
}
} else if(recipient.isOpenGroupInboxRecipient) {
- val publicKey = GroupUtil.getDecodedOpenGroupInbox(recipient.address.serialize())
+ val publicKey = GroupUtil.getDecodedOpenGroupInboxSessionId(recipient.address.serialize())
this.publicKey = publicKey
displayName = getUserDisplayName(publicKey)
additionalPublicKey = null
@@ -91,8 +88,8 @@ class ProfilePictureView @JvmOverloads constructor(
val publicKey = publicKey ?: return
val additionalPublicKey = additionalPublicKey
if (additionalPublicKey != null) {
- setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName, R.dimen.small_profile_picture_size)
- setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName, R.dimen.small_profile_picture_size)
+ setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName)
+ setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName)
binding.doubleModeImageViewContainer.visibility = View.VISIBLE
} else {
glide.clear(binding.doubleModeImageView1)
@@ -100,14 +97,14 @@ class ProfilePictureView @JvmOverloads constructor(
binding.doubleModeImageViewContainer.visibility = View.INVISIBLE
}
if (additionalPublicKey == null && !isLarge) {
- setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName, R.dimen.medium_profile_picture_size)
+ setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName)
binding.singleModeImageView.visibility = View.VISIBLE
} else {
glide.clear(binding.singleModeImageView)
binding.singleModeImageView.visibility = View.INVISIBLE
}
if (additionalPublicKey == null && isLarge) {
- setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName, R.dimen.large_profile_picture_size)
+ setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName)
binding.largeSingleModeImageView.visibility = View.VISIBLE
} else {
glide.clear(binding.largeSingleModeImageView)
@@ -115,17 +112,19 @@ class ProfilePictureView @JvmOverloads constructor(
}
}
- private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?, @DimenRes sizeResId: Int) {
+ private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?) {
if (publicKey.isNotEmpty()) {
val recipient = Recipient.from(context, Address.fromSerialized(publicKey), false)
- if (profilePicturesCache.containsKey(publicKey) && profilePicturesCache[publicKey] == recipient.profileAvatar) return
+ if (profilePicturesCache[imageView] == recipient) return
+ profilePicturesCache[imageView] = recipient
val signalProfilePicture = recipient.contactPhoto
val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject
- val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
+ glide.clear(imageView)
+
+ val placeholder = PlaceholderAvatarPhoto(publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
if (signalProfilePicture != null && avatar != "0" && avatar != "") {
- glide.clear(imageView)
glide.load(signalProfilePicture)
.placeholder(unknownRecipientDrawable)
.centerCrop()
@@ -133,21 +132,19 @@ class ProfilePictureView @JvmOverloads constructor(
.diskCacheStrategy(DiskCacheStrategy.NONE)
.circleCrop()
.into(imageView)
- } else if (recipient.isOpenGroupRecipient && recipient.groupAvatarId == null) {
+ } else if (recipient.isCommunityRecipient && recipient.groupAvatarId == null) {
glide.clear(imageView)
glide.load(unknownOpenGroupDrawable)
.centerCrop()
.circleCrop()
.into(imageView)
} else {
- glide.clear(imageView)
glide.load(placeholder)
.placeholder(unknownRecipientDrawable)
.centerCrop()
.circleCrop()
.diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView)
}
- profilePicturesCache[publicKey] = recipient.profileAvatar
} else {
glide.load(unknownRecipientDrawable)
.centerCrop()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java
index 8a56acd658..9032b26a2b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java
@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.components;
-
import android.animation.Animator;
import android.content.Context;
import android.os.Build;
@@ -68,9 +67,7 @@ public class SearchToolbar extends LinearLayout {
}
@Override
- public boolean onQueryTextChange(String newText) {
- return onQueryTextSubmit(newText);
- }
+ public boolean onQueryTextChange(String newText) { return onQueryTextSubmit(newText); }
});
searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/RecentEmojiPageModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/RecentEmojiPageModel.java
index 6cc39c8d14..0e2de9068c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/RecentEmojiPageModel.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/RecentEmojiPageModel.java
@@ -3,133 +3,95 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
-import android.os.AsyncTask;
import android.preference.PreferenceManager;
-import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
-import com.fasterxml.jackson.databind.type.CollectionType;
-import com.fasterxml.jackson.databind.type.TypeFactory;
import org.session.libsignal.utilities.JsonUtil;
import org.session.libsignal.utilities.Log;
-import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
-import java.util.Iterator;
-import java.util.LinkedHashSet;
+import java.util.LinkedList;
import java.util.List;
import network.loki.messenger.R;
public class RecentEmojiPageModel implements EmojiPageModel {
private static final String TAG = RecentEmojiPageModel.class.getSimpleName();
- private static final String EMOJI_LRU_PREFERENCE = "pref_recent_emoji2";
- private static final int EMOJI_LRU_SIZE = 50;
- public static final String KEY = "Recents";
- public static final List DEFAULT_REACTIONS_LIST =
- Arrays.asList("\ud83d\ude02", "\ud83e\udd70", "\ud83d\ude22", "\ud83d\ude21", "\ud83d\ude2e", "\ud83d\ude08");
+ public static final String RECENT_EMOJIS_KEY = "Recents";
- private final SharedPreferences prefs;
- private final LinkedHashSet recentlyUsed;
+ public static final LinkedList DEFAULT_REACTION_EMOJIS_LIST = new LinkedList<>(Arrays.asList(
+ "\ud83d\ude02",
+ "\ud83e\udd70",
+ "\ud83d\ude22",
+ "\ud83d\ude21",
+ "\ud83d\ude2e",
+ "\ud83d\ude08"));
+
+ public static final String DEFAULT_REACTION_EMOJIS_JSON_STRING = JsonUtil.toJson(new LinkedList<>(DEFAULT_REACTION_EMOJIS_LIST));
+ private static SharedPreferences prefs;
+ private static LinkedList recentlyUsed;
public RecentEmojiPageModel(Context context) {
- this.prefs = PreferenceManager.getDefaultSharedPreferences(context);
- this.recentlyUsed = getPersistedCache();
- }
+ prefs = PreferenceManager.getDefaultSharedPreferences(context);
- private LinkedHashSet getPersistedCache() {
- String serialized = prefs.getString(EMOJI_LRU_PREFERENCE, "[]");
- try {
- CollectionType collectionType = TypeFactory.defaultInstance()
- .constructCollectionType(LinkedHashSet.class, String.class);
- return JsonUtil.getMapper().readValue(serialized, collectionType);
- } catch (IOException e) {
- Log.w(TAG, e);
- return new LinkedHashSet<>();
- }
+ // Note: Do NOT try to populate or update the persisted recent emojis in the constructor - the
+ // `getEmoji` method ends up getting called half-way through in a race-condition manner.
}
@Override
- public String getKey() {
- return KEY;
- }
+ public String getKey() { return RECENT_EMOJIS_KEY; }
- @Override public int getIconAttr() {
- return R.attr.emoji_category_recent;
- }
+ @Override public int getIconAttr() { return R.attr.emoji_category_recent; }
@Override public List getEmoji() {
- List recent = new ArrayList<>(recentlyUsed);
- List out = new ArrayList<>(DEFAULT_REACTIONS_LIST.size());
-
- for (int i = 0; i < DEFAULT_REACTIONS_LIST.size(); i++) {
- if (recent.size() > i) {
- out.add(recent.get(i));
- } else {
- out.add(DEFAULT_REACTIONS_LIST.get(i));
+ // Populate our recently used list if required (i.e., on first run)
+ if (recentlyUsed == null) {
+ try {
+ String recentlyUsedEmjoiJsonString = prefs.getString(RECENT_EMOJIS_KEY, DEFAULT_REACTION_EMOJIS_JSON_STRING);
+ recentlyUsed = JsonUtil.fromJson(recentlyUsedEmjoiJsonString, LinkedList.class);
+ } catch (Exception e) {
+ Log.w(TAG, e);
+ Log.d(TAG, "Default reaction emoji data was corrupt (likely via key re-use on app upgrade) - rewriting fresh data.");
+ boolean writeSuccess = prefs.edit().putString(RECENT_EMOJIS_KEY, DEFAULT_REACTION_EMOJIS_JSON_STRING).commit();
+ if (!writeSuccess) { Log.w(TAG, "Failed to update recently used emojis in shared prefs."); }
+ recentlyUsed = DEFAULT_REACTION_EMOJIS_LIST;
}
}
-
- return out;
+ return new ArrayList<>(recentlyUsed);
}
@Override public List getDisplayEmoji() {
return Stream.of(getEmoji()).map(Emoji::new).toList();
}
- @Override public boolean hasSpriteMap() {
- return false;
- }
+ @Override public boolean hasSpriteMap() { return false; }
@Nullable
@Override
- public Uri getSpriteUri() {
- return null;
- }
+ public Uri getSpriteUri() { return null; }
- @Override public boolean isDynamic() {
- return true;
- }
+ @Override public boolean isDynamic() { return true; }
- public void onCodePointSelected(String emoji) {
- recentlyUsed.remove(emoji);
- recentlyUsed.add(emoji);
+ public static void onCodePointSelected(String emoji) {
+ // If the emoji is already in the recently used list then remove it..
+ if (recentlyUsed.contains(emoji)) { recentlyUsed.removeFirstOccurrence(emoji); }
- if (recentlyUsed.size() > EMOJI_LRU_SIZE) {
- Iterator iterator = recentlyUsed.iterator();
- iterator.next();
- iterator.remove();
- }
+ // ..and then regardless of whether the emoji used was already in the recently used list or not
+ // it gets placed as the first element in the list..
+ recentlyUsed.addFirst(emoji);
- final LinkedHashSet latestRecentlyUsed = new LinkedHashSet<>(recentlyUsed);
- new AsyncTask() {
+ // Ensure that we only ever store data for a maximum of 6 recently used emojis (this code will
+ // execute if if we did NOT remove any occurrence of a previously used emoji but then added the
+ // new emoji to the front of the list).
+ while (recentlyUsed.size() > 6) { recentlyUsed.removeLast(); }
- @Override
- protected Void doInBackground(Void... params) {
- try {
- String serialized = JsonUtil.toJsonThrows(latestRecentlyUsed);
- prefs.edit()
- .putString(EMOJI_LRU_PREFERENCE, serialized)
- .apply();
- } catch (IOException e) {
- Log.w(TAG, e);
- }
-
- return null;
- }
- }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
- }
-
- private String[] toReversePrimitiveArray(@NonNull LinkedHashSet emojiSet) {
- String[] emojis = new String[emojiSet.size()];
- int i = emojiSet.size() - 1;
- for (String emoji : emojiSet) {
- emojis[i--] = emoji;
- }
- return emojis;
+ // ..which we then save to shared prefs.
+ String recentlyUsedAsJsonString = JsonUtil.toJson(recentlyUsed);
+ boolean writeSuccess = prefs.edit().putString(RECENT_EMOJIS_KEY, recentlyUsedAsJsonString).commit();
+ if (!writeSuccess) { Log.w(TAG, "Failed to update recently used emojis in shared prefs."); }
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiPageBitmap.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiPageBitmap.java
deleted file mode 100644
index 3c3a4fa3eb..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/parsing/EmojiPageBitmap.java
+++ /dev/null
@@ -1,107 +0,0 @@
-package org.thoughtcrime.securesms.components.emoji.parsing;
-
-import android.annotation.SuppressLint;
-import android.content.Context;
-import android.content.res.AssetManager;
-import android.graphics.Bitmap;
-import android.graphics.BitmapFactory;
-import android.os.AsyncTask;
-import androidx.annotation.NonNull;
-import org.session.libsignal.utilities.Log;
-
-import org.thoughtcrime.securesms.components.emoji.EmojiPageModel;
-import org.thoughtcrime.securesms.util.Stopwatch;
-
-import org.session.libsession.utilities.ListenableFutureTask;
-import org.session.libsession.utilities.Util;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.lang.ref.SoftReference;
-import java.util.concurrent.Callable;
-
-public class EmojiPageBitmap {
-
- private static final String TAG = EmojiPageBitmap.class.getSimpleName();
-
- private final Context context;
- private final EmojiPageModel model;
- private final float decodeScale;
-
- private SoftReference bitmapReference;
- private ListenableFutureTask task;
-
- public EmojiPageBitmap(@NonNull Context context, @NonNull EmojiPageModel model, float decodeScale) {
- this.context = context.getApplicationContext();
- this.model = model;
- this.decodeScale = decodeScale;
- }
-
- @SuppressLint("StaticFieldLeak")
- public ListenableFutureTask get() {
- Util.assertMainThread();
-
- if (bitmapReference != null && bitmapReference.get() != null) {
- return new ListenableFutureTask<>(bitmapReference.get());
- } else if (task != null) {
- return task;
- } else {
- Callable callable = () -> {
- try {
- Log.i(TAG, "loading page " + model.getSpriteUri().toString());
- return loadPage();
- } catch (IOException ioe) {
- Log.w(TAG, ioe);
- }
- return null;
- };
- task = new ListenableFutureTask<>(callable);
- new AsyncTask() {
- @Override protected Void doInBackground(Void... params) {
- task.run();
- return null;
- }
-
- @Override protected void onPostExecute(Void aVoid) {
- task = null;
- }
- }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
- }
- return task;
- }
-
- private Bitmap loadPage() throws IOException {
- if (bitmapReference != null && bitmapReference.get() != null) return bitmapReference.get();
-
-
- float scale = decodeScale;
- AssetManager assetManager = context.getAssets();
- InputStream assetStream = assetManager.open(model.getSpriteUri().toString());
- BitmapFactory.Options options = new BitmapFactory.Options();
-
- if (org.thoughtcrime.securesms.util.Util.isLowMemory(context)) {
- Log.i(TAG, "Low memory detected. Changing sample size.");
- options.inSampleSize = 2;
- scale = decodeScale * 2;
- }
-
- Stopwatch stopwatch = new Stopwatch(model.getSpriteUri().toString());
- Bitmap bitmap = BitmapFactory.decodeStream(assetStream, null, options);
- stopwatch.split("decode");
-
- Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, (int)(bitmap.getWidth() * scale), (int)(bitmap.getHeight() * scale), true);
- stopwatch.split("scale");
- stopwatch.stop(TAG);
-
- bitmapReference = new SoftReference<>(scaledBitmap);
- Log.i(TAG, "onPageLoaded(" + model.getSpriteUri().toString() + ") originalByteCount: " + bitmap.getByteCount()
- + " scaledByteCount: " + scaledBitmap.getByteCount()
- + " scaledSize: " + scaledBitmap.getWidth() + "x" + scaledBitmap.getHeight());
- return scaledBitmap;
- }
-
- @Override
- public @NonNull String toString() {
- return model.getSpriteUri().toString();
- }
-}
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..69dec0cdd6 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,13 +1,23 @@
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.session.libsession.utilities.getColorFromAttr
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
@@ -34,30 +44,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 +71,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 = ColorStateList.valueOf(color ?: context.getColorFromAttr(android.R.attr.textColor))
}
- 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/components/recyclerview/SmoothScrollingLinearLayoutManager.java b/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/SmoothScrollingLinearLayoutManager.java
deleted file mode 100644
index a1b45ac2ae..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/components/recyclerview/SmoothScrollingLinearLayoutManager.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package org.thoughtcrime.securesms.components.recyclerview;
-
-import android.content.Context;
-import androidx.annotation.NonNull;
-import androidx.recyclerview.widget.LinearLayoutManager;
-import androidx.recyclerview.widget.LinearSmoothScroller;
-import android.util.DisplayMetrics;
-
-public class SmoothScrollingLinearLayoutManager extends LinearLayoutManager {
-
- public SmoothScrollingLinearLayoutManager(Context context, boolean reverseLayout) {
- super(context, LinearLayoutManager.VERTICAL, reverseLayout);
- }
-
- public void smoothScrollToPosition(@NonNull Context context, int position, float millisecondsPerInch) {
- final LinearSmoothScroller scroller = new LinearSmoothScroller(context) {
- @Override
- protected int getVerticalSnapPreference() {
- return LinearSmoothScroller.SNAP_TO_END;
- }
-
- @Override
- protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
- return millisecondsPerInch / displayMetrics.densityDpi;
- }
- };
-
- scroller.setTargetPosition(position);
- startSmoothScroll(scroller);
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt
index 3a2b2cbb5c..90e0ce50f2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListLoader.kt
@@ -58,7 +58,7 @@ class ContactSelectionListLoader(context: Context, val mode: Int, val filter: St
private fun getOpenGroups(contacts: List): List {
return getItems(contacts, context.getString(R.string.fragment_contact_selection_open_groups_title)) {
- it.address.isOpenGroup
+ it.address.isCommunity
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactUtil.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactUtil.java
similarity index 89%
rename from app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactUtil.java
rename to app/src/main/java/org/thoughtcrime/securesms/contacts/ContactUtil.java
index 4a1059ffd9..5284fb0015 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactUtil.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactUtil.java
@@ -1,4 +1,4 @@
-package org.thoughtcrime.securesms.contactshare;
+package org.thoughtcrime.securesms.contacts;
import android.content.Context;
import androidx.annotation.NonNull;
@@ -24,7 +24,7 @@ public final class ContactUtil {
return SpanUtil.italic(context.getString(R.string.MessageNotifier_unknown_contact_message));
}
- public static @NonNull String getDisplayName(@Nullable Contact contact) {
+ private static @NonNull String getDisplayName(@Nullable Contact contact) {
if (contact == null) {
return "";
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactModelMapper.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactModelMapper.java
deleted file mode 100644
index ef783da791..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactModelMapper.java
+++ /dev/null
@@ -1,169 +0,0 @@
-package org.thoughtcrime.securesms.contactshare;
-
-import androidx.annotation.NonNull;
-
-import org.session.libsession.messaging.sending_receiving.attachments.Attachment;
-import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment;
-import org.session.libsignal.utilities.guava.Optional;
-import org.session.libsignal.messages.SharedContact;
-
-import java.util.ArrayList;
-import java.util.LinkedList;
-import java.util.List;
-
-import org.session.libsession.utilities.Contact;
-import static org.session.libsession.utilities.Contact.*;
-
-public class ContactModelMapper {
-
- public static SharedContact.Builder localToRemoteBuilder(@NonNull Contact contact) {
- List phoneNumbers = new ArrayList<>(contact.getPhoneNumbers().size());
- List emails = new ArrayList<>(contact.getEmails().size());
- List postalAddresses = new ArrayList<>(contact.getPostalAddresses().size());
-
- for (Phone phone : contact.getPhoneNumbers()) {
- phoneNumbers.add(new SharedContact.Phone.Builder().setValue(phone.getNumber())
- .setType(localToRemoteType(phone.getType()))
- .setLabel(phone.getLabel())
- .build());
- }
-
- for (Email email : contact.getEmails()) {
- emails.add(new SharedContact.Email.Builder().setValue(email.getEmail())
- .setType(localToRemoteType(email.getType()))
- .setLabel(email.getLabel())
- .build());
- }
-
- for (PostalAddress postalAddress : contact.getPostalAddresses()) {
- postalAddresses.add(new SharedContact.PostalAddress.Builder().setType(localToRemoteType(postalAddress.getType()))
- .setLabel(postalAddress.getLabel())
- .setStreet(postalAddress.getStreet())
- .setPobox(postalAddress.getPoBox())
- .setNeighborhood(postalAddress.getNeighborhood())
- .setCity(postalAddress.getCity())
- .setRegion(postalAddress.getRegion())
- .setPostcode(postalAddress.getPostalCode())
- .setCountry(postalAddress.getCountry())
- .build());
- }
-
- SharedContact.Name name = new SharedContact.Name.Builder().setDisplay(contact.getName().getDisplayName())
- .setGiven(contact.getName().getGivenName())
- .setFamily(contact.getName().getFamilyName())
- .setPrefix(contact.getName().getPrefix())
- .setSuffix(contact.getName().getSuffix())
- .setMiddle(contact.getName().getMiddleName())
- .build();
-
- return new SharedContact.Builder().setName(name)
- .withOrganization(contact.getOrganization())
- .withPhones(phoneNumbers)
- .withEmails(emails)
- .withAddresses(postalAddresses);
- }
-
- public static Contact remoteToLocal(@NonNull SharedContact sharedContact) {
- Name name = new Name(sharedContact.getName().getDisplay().orNull(),
- sharedContact.getName().getGiven().orNull(),
- sharedContact.getName().getFamily().orNull(),
- sharedContact.getName().getPrefix().orNull(),
- sharedContact.getName().getSuffix().orNull(),
- sharedContact.getName().getMiddle().orNull());
-
- List phoneNumbers = new LinkedList<>();
- if (sharedContact.getPhone().isPresent()) {
- for (SharedContact.Phone phone : sharedContact.getPhone().get()) {
- phoneNumbers.add(new Phone(phone.getValue(),
- remoteToLocalType(phone.getType()),
- phone.getLabel().orNull()));
- }
- }
-
- List emails = new LinkedList<>();
- if (sharedContact.getEmail().isPresent()) {
- for (SharedContact.Email email : sharedContact.getEmail().get()) {
- emails.add(new Email(email.getValue(),
- remoteToLocalType(email.getType()),
- email.getLabel().orNull()));
- }
- }
-
- List postalAddresses = new LinkedList<>();
- if (sharedContact.getAddress().isPresent()) {
- for (SharedContact.PostalAddress postalAddress : sharedContact.getAddress().get()) {
- postalAddresses.add(new PostalAddress(remoteToLocalType(postalAddress.getType()),
- postalAddress.getLabel().orNull(),
- postalAddress.getStreet().orNull(),
- postalAddress.getPobox().orNull(),
- postalAddress.getNeighborhood().orNull(),
- postalAddress.getCity().orNull(),
- postalAddress.getRegion().orNull(),
- postalAddress.getPostcode().orNull(),
- postalAddress.getCountry().orNull()));
- }
- }
-
- Avatar avatar = null;
- if (sharedContact.getAvatar().isPresent()) {
- Attachment attachment = PointerAttachment.forPointer(Optional.of(sharedContact.getAvatar().get().getAttachment().asPointer())).get();
- boolean isProfile = sharedContact.getAvatar().get().isProfile();
-
- avatar = new Avatar(null, attachment, isProfile);
- }
-
- return new Contact(name, sharedContact.getOrganization().orNull(), phoneNumbers, emails, postalAddresses, avatar);
- }
-
- private static Phone.Type remoteToLocalType(SharedContact.Phone.Type type) {
- switch (type) {
- case HOME: return Phone.Type.HOME;
- case MOBILE: return Phone.Type.MOBILE;
- case WORK: return Phone.Type.WORK;
- default: return Phone.Type.CUSTOM;
- }
- }
-
- private static Email.Type remoteToLocalType(SharedContact.Email.Type type) {
- switch (type) {
- case HOME: return Email.Type.HOME;
- case MOBILE: return Email.Type.MOBILE;
- case WORK: return Email.Type.WORK;
- default: return Email.Type.CUSTOM;
- }
- }
-
- private static PostalAddress.Type remoteToLocalType(SharedContact.PostalAddress.Type type) {
- switch (type) {
- case HOME: return PostalAddress.Type.HOME;
- case WORK: return PostalAddress.Type.WORK;
- default: return PostalAddress.Type.CUSTOM;
- }
- }
-
- private static SharedContact.Phone.Type localToRemoteType(Phone.Type type) {
- switch (type) {
- case HOME: return SharedContact.Phone.Type.HOME;
- case MOBILE: return SharedContact.Phone.Type.MOBILE;
- case WORK: return SharedContact.Phone.Type.WORK;
- default: return SharedContact.Phone.Type.CUSTOM;
- }
- }
-
- private static SharedContact.Email.Type localToRemoteType(Email.Type type) {
- switch (type) {
- case HOME: return SharedContact.Email.Type.HOME;
- case MOBILE: return SharedContact.Email.Type.MOBILE;
- case WORK: return SharedContact.Email.Type.WORK;
- default: return SharedContact.Email.Type.CUSTOM;
- }
- }
-
- private static SharedContact.PostalAddress.Type localToRemoteType(PostalAddress.Type type) {
- switch (type) {
- case HOME: return SharedContact.PostalAddress.Type.HOME;
- case WORK: return SharedContact.PostalAddress.Type.WORK;
- default: return SharedContact.PostalAddress.Type.CUSTOM;
- }
- }
-}
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..184869b9ad
--- /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.isCommunityRecipient) {
+ 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..ced4cc0035
--- /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_send_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/start/ContactListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt
index 68e2f975c9..df2bc1c371 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/ContactListAdapter.kt
@@ -35,11 +35,28 @@ class ContactListAdapter(
binding.profilePictureView.update(contact.recipient)
binding.nameTextView.text = contact.displayName
binding.root.setOnClickListener { listener(contact.recipient) }
+
+ // TODO: When we implement deleting contacts (hide might be safest for now) then probably set a long-click listener here w/ something like:
+ /*
+ binding.root.setOnLongClickListener {
+ Log.w("[ACL]", "Long clicked on contact ${contact.recipient.name}")
+ binding.contentView.context.showSessionDialog {
+ title("Delete Contact")
+ text("Are you sure you want to delete this contact?")
+ button(R.string.delete) {
+ val contacts = configFactory.contacts ?: return
+ contacts.upsertContact(contact.recipient.address.serialize()) { priority = PRIORITY_HIDDEN }
+ ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
+ endActionMode()
+ }
+ cancelButton(::endActionMode)
+ }
+ true
+ }
+ */
}
- fun unbind() {
- binding.profilePictureView.recycle()
- }
+ fun unbind() { binding.profilePictureView.recycle() }
}
class HeaderViewHolder(
@@ -52,15 +69,11 @@ class ContactListAdapter(
}
}
- override fun getItemCount(): Int {
- return items.size
- }
+ override fun getItemCount(): Int { return items.size }
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
super.onViewRecycled(holder)
- if (holder is ContactViewHolder) {
- holder.unbind()
- }
+ if (holder is ContactViewHolder) { holder.unbind() }
}
override fun getItemViewType(position: Int): Int {
@@ -72,13 +85,9 @@ class ContactListAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == ViewType.Contact) {
- ContactViewHolder(
- ViewContactBinding.inflate(LayoutInflater.from(context), parent, false)
- )
+ ContactViewHolder(ViewContactBinding.inflate(LayoutInflater.from(context), parent, false))
} else {
- HeaderViewHolder(
- ContactSectionHeaderBinding.inflate(LayoutInflater.from(context), parent, false)
- )
+ HeaderViewHolder(ContactSectionHeaderBinding.inflate(LayoutInflater.from(context), parent, false))
}
}
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 233d43eaeb..187ded770e 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,23 +30,22 @@ 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
+import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import androidx.recyclerview.widget.LinearLayoutManager
@@ -57,13 +56,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.consumeAsFlow
-import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.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
@@ -71,14 +69,14 @@ 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
import org.session.libsession.messaging.messages.visible.VisibleMessage
import org.session.libsession.messaging.open_groups.OpenGroupApi
-import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
@@ -104,14 +102,16 @@ import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.attachments.ScreenshotObserver
import org.thoughtcrime.securesms.audio.AudioRecorder
+import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
-import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
+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
+import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_REPLY
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_RESEND
-import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_DELETE
import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog
@@ -127,19 +127,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
@@ -167,15 +164,17 @@ 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
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SaveAttachmentTask
+import org.thoughtcrime.securesms.util.SimpleTextWatcher
import org.thoughtcrime.securesms.util.isScrolledToBottom
+import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom
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.util.Locale
@@ -189,6 +188,8 @@ import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sqrt
+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
// price we pay is a bit of back and forth between the input bar and the conversation activity.
@@ -196,7 +197,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 +209,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
@@ -240,11 +239,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val address = if (sessionId.prefix == IdPrefix.BLINDED && openGroup != null) {
storage.getOrCreateBlindedIdMapping(sessionId.hexString, openGroup.server, openGroup.publicKey).sessionId?.let {
fromSerialized(it)
- } ?: run {
- val openGroupInboxId =
- "${openGroup.server}!${openGroup.publicKey}!${sessionId.hexString}".toByteArray()
- fromSerialized(GroupUtil.getEncodedOpenGroupInboxID(openGroupInboxId))
- }
+ } ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, sessionId)
} else {
it
}
@@ -253,10 +248,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
} ?: finish()
}
- viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair(), contentResolver)
+ viewModelFactory.create(threadId, MessagingModuleConfiguration.shared.getUserED25519KeyPair())
}
private var actionMode: ActionMode? = null
- private var unreadCount = 0
+ private var unreadCount = Int.MAX_VALUE
// Attachments
private val audioRecorder = AudioRecorder(this)
private val stopAudioHandler = Handler(Looper.getMainLooper())
@@ -280,6 +275,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private val isScrolledToBottom: Boolean
get() = binding?.conversationRecyclerView?.isScrolledToBottom ?: true
+ private val isScrolledToWithin30dpOfBottom: Boolean
+ get() = binding?.conversationRecyclerView?.isScrolledToWithin30dpOfBottom ?: true
+
private val layoutManager: LinearLayoutManager?
get() { return binding?.conversationRecyclerView?.layoutManager as LinearLayoutManager? }
@@ -288,8 +286,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (hexEncodedSeed == null) {
hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(this).hexEncodedPrivateKey // Legacy account
}
+
+ val appContext = applicationContext
val loadFileContents: (String) -> String = { fileName ->
- MnemonicUtilities.loadFileContents(this, fileName)
+ MnemonicUtilities.loadFileContents(appContext, fileName)
}
MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english)
}
@@ -312,8 +312,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
handleSwipeToReply(message)
},
onItemLongPress = { message, position, view ->
- if (!isMessageRequestThread() &&
- (viewModel.openGroup == null || Capability.REACTIONS.name.lowercase() in viewModel.serverCapabilities)
+ if (!viewModel.isMessageRequestThread &&
+ viewModel.canReactToMessages
) {
showEmojiPicker(message, view)
} else {
@@ -326,7 +326,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
},
onAttachmentNeedsDownload = { attachmentId, mmsId ->
- // Start download (on IO thread)
lifecycleScope.launch(Dispatchers.IO) {
JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId))
}
@@ -335,6 +334,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
lifecycleCoroutineScope = lifecycleScope
)
adapter.visibleMessageViewDelegate = this
+
+ // Register an AdapterDataObserver to scroll us to the bottom of the RecyclerView for if
+ // we're already near the the bottom and the data changes.
+ adapter.registerAdapterDataObserver(ConversationAdapterDataObserver(binding?.conversationRecyclerView!!, adapter))
+
adapter
}
@@ -351,6 +355,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private lateinit var reactionDelegate: ConversationReactionDelegate
private val reactWithAnyEmojiStartPage = -1
+ // Properties for what message indices are visible previously & now, as well as the scroll state
+ private var previousLastVisibleRecyclerViewIndex: Int = RecyclerView.NO_POSITION
+ private var currentLastVisibleRecyclerViewIndex: Int = RecyclerView.NO_POSITION
+ private var recyclerScrollState: Int = RecyclerView.SCROLL_STATE_IDLE
+
// region Settings
companion object {
// Extras
@@ -365,7 +374,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
const val PICK_GIF = 10
const val PICK_FROM_LIBRARY = 12
const val INVITE_CONTACTS = 124
-
}
// endregion
@@ -374,12 +382,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
super.onCreate(savedInstanceState, isReady)
binding = ActivityConversationV2Binding.inflate(layoutInflater)
setContentView(binding!!.root)
+
// messageIdToScroll
messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1))
messageToScrollAuthor.set(intent.getParcelableExtra(SCROLL_MESSAGE_AUTHOR))
val recipient = viewModel.recipient
val openGroup = recipient.let { viewModel.openGroup }
- if (recipient == null || (recipient.isOpenGroupRecipient && openGroup == null)) {
+ if (recipient == null || (recipient.isCommunityRecipient && openGroup == null)) {
Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show()
return finish()
}
@@ -389,6 +398,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpLinkPreviewObserver()
restoreDraftIfNeeded()
setUpUiStateObserver()
+
binding!!.scrollToBottomButton.setOnClickListener {
val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener
val targetPosition = if (reverseMessageList) 0 else adapter.itemCount
@@ -414,14 +424,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
updateUnreadCountIndicator()
- updateSubtitle()
updatePlaceholder()
setUpBlockedBanner()
binding!!.searchBottomBar.setEventListener(this)
updateSendAfterApprovalText()
- showOrHideInputIfNeeded()
setUpMessageRequestsBar()
+ // Note: Do not `showOrHideInputIfNeeded` here - we'll never start this activity w/ the
+ // keyboard visible and have no need to immediately display it.
+
val weakActivity = WeakReference(this)
lifecycleScope.launch(Dispatchers.IO) {
@@ -442,6 +453,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpRecipientObserver()
getLatestOpenGroupInfoIfNeeded()
setUpSearchResultObserver()
+ scrollToFirstUnreadMessageIfNeeded()
+ setUpOutdatedClientBanner()
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
binding?.conversationRecyclerView?.scrollToPosition(targetPosition)
@@ -457,18 +470,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)
+ }
+ }
}
- }
- }
}
}
@@ -481,6 +497,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
true,
screenshotObserver
)
+ viewModel.run {
+ binding?.toolbarContent?.update(recipient ?: return, openGroup, expirationConfiguration)
+ }
}
override fun onPause() {
@@ -497,8 +516,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?) {
@@ -530,16 +548,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) {
@@ -556,17 +574,46 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding!!.conversationRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ // The unreadCount check is to prevent us scrolling to the bottom when we first enter a conversation
+ if (recyclerScrollState == RecyclerView.SCROLL_STATE_IDLE && unreadCount != Int.MAX_VALUE) {
+ scrollToMostRecentMessageIfWeShould()
+ }
handleRecyclerViewScrolled()
}
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
-
+ recyclerScrollState = newState
}
})
+ }
- binding!!.conversationRecyclerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
- showScrollToBottomButtonIfApplicable()
+ private fun scrollToMostRecentMessageIfWeShould() {
+ // Grab an initial 'previous' last visible message..
+ if (previousLastVisibleRecyclerViewIndex == RecyclerView.NO_POSITION) {
+ previousLastVisibleRecyclerViewIndex = layoutManager?.findLastVisibleItemPosition()!!
}
+
+ // ..and grab the 'current' last visible message.
+ currentLastVisibleRecyclerViewIndex = layoutManager?.findLastVisibleItemPosition()!!
+
+ // If the current last visible message index is less than the previous one (i.e. we've
+ // lost visibility of one or more messages due to showing the IME keyboard) AND we're
+ // at the bottom of the message feed..
+ val atBottomAndTrueLastNoLongerVisible = currentLastVisibleRecyclerViewIndex!! <= previousLastVisibleRecyclerViewIndex!! && !binding?.scrollToBottomButton?.isVisible!!
+
+ // ..OR we're at the last message or have received a new message..
+ val atLastOrReceivedNewMessage = currentLastVisibleRecyclerViewIndex == (adapter.itemCount - 1)
+
+ // ..then scroll the recycler view to the last message on resize. Note: We cannot just call
+ // scroll/smoothScroll - we have to `post` it or nothing happens!
+ if (atBottomAndTrueLastNoLongerVisible || atLastOrReceivedNewMessage) {
+ binding?.conversationRecyclerView?.post {
+ binding?.conversationRecyclerView?.smoothScrollToPosition(adapter.itemCount)
+ }
+ }
+
+ // Update our previous last visible view index to the current one
+ previousLastVisibleRecyclerViewIndex = currentLastVisibleRecyclerViewIndex
}
// called from onCreate
@@ -578,44 +625,39 @@ 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
private fun setUpInputBar() {
- binding!!.inputBar.isVisible = viewModel.openGroup == null || viewModel.openGroup?.canWrite == true
- binding!!.inputBar.delegate = this
- binding!!.inputBarRecordingView.delegate = this
+ val binding = binding ?: return
+ binding.inputBar.isGone = viewModel.hidesInputBar()
+ binding.inputBar.delegate = this
+ binding.inputBarRecordingView.delegate = this
// GIF button
- binding!!.gifButtonContainer.addView(gifButton)
+ binding.gifButtonContainer.addView(gifButton)
gifButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
gifButton.onUp = { showGIFPicker() }
gifButton.snIsEnabled = false
// Document button
- binding!!.documentButtonContainer.addView(documentButton)
+ binding.documentButtonContainer.addView(documentButton)
documentButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
documentButton.onUp = { showDocumentPicker() }
documentButton.snIsEnabled = false
// Library button
- binding!!.libraryButtonContainer.addView(libraryButton)
+ binding.libraryButtonContainer.addView(libraryButton)
libraryButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
libraryButton.onUp = { pickFromLibrary() }
libraryButton.snIsEnabled = false
// Camera button
- binding!!.cameraButtonContainer.addView(cameraButton)
+ binding.cameraButtonContainer.addView(cameraButton)
cameraButton.layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT)
cameraButton.onUp = { showCamera() }
cameraButton.snIsEnabled = false
@@ -682,23 +724,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
@@ -745,13 +800,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// of the first unread message in the middle of the screen
if (isFirstLoad && !reverseMessageList) {
layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2))
-
if (shouldHighlight) { highlightViewAtPosition(lastSeenItemPosition) }
-
return lastSeenItemPosition
}
if (lastSeenItemPosition <= 3) { return lastSeenItemPosition }
+
binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition)
return lastSeenItemPosition
}
@@ -764,24 +818,24 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val recipient = viewModel.recipient ?: return false
- if (!isMessageRequestThread()) {
+ if (!viewModel.isMessageRequestThread) {
ConversationMenuHelper.onPrepareOptionsMenu(
menu,
menuInflater,
recipient,
- viewModel.threadId,
this
- ) { onOptionsItemSelected(it) }
+ )
}
+ maybeUpdateToolbar(recipient)
return true
}
override fun onDestroy() {
viewModel.saveDraft(binding?.inputBar?.text?.trim() ?: "")
+ cancelVoiceMessage()
tearDownRecipientObserver()
super.onDestroy()
binding = null
-// actionBarBinding = null
}
// endregion
@@ -796,31 +850,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() {
@@ -850,26 +898,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
- private fun isMessageRequestThread(): Boolean {
- val recipient = viewModel.recipient ?: return false
- return !recipient.isGroupRecipient && !recipient.isApproved
- }
+ private fun isOutgoingMessageRequestThread(): Boolean = viewModel.recipient?.run {
+ !isGroupRecipient && !isLocalNumber &&
+ !(hasApprovedMe() || viewModel.hasReceived())
+ } ?: false
- private fun isOutgoingMessageRequestThread(): Boolean {
- val recipient = viewModel.recipient ?: return false
- return !recipient.isGroupRecipient &&
- !recipient.isLocalNumber &&
- !(recipient.hasApprovedMe() || viewModel.hasReceived())
- }
-
- 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
@@ -934,11 +971,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
view.glide = glide
view.onCandidateSelected = { handleMentionSelected(it) }
additionalContentContainer.addView(view)
- val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isOpenGroupRecipient)
+ val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
this.mentionCandidatesView = view
view.show(candidates, viewModel.threadId)
} else {
- val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isOpenGroupRecipient)
+ val candidates = MentionsManager.getMentionCandidates(query, viewModel.threadId, recipient.isCommunityRecipient)
this.mentionCandidatesView!!.setMentionCandidates(candidates)
}
isShowingMentionCandidatesView = true
@@ -984,7 +1021,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
override fun showVoiceMessageUI() {
- binding?.inputBarRecordingView?.show()
+ binding?.inputBarRecordingView?.show(lifecycleScope)
binding?.inputBar?.alpha = 0.0f
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
animation.duration = 250L
@@ -1043,22 +1080,26 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun handleRecyclerViewScrolled() {
val binding = binding ?: return
+
+ // Note: The typing indicate is whether the other person / other people are typing - it has
+ // nothing to do with the IME keyboard state.
val wasTypingIndicatorVisibleBefore = binding.typingIndicatorViewContainer.isVisible
binding.typingIndicatorViewContainer.isVisible = wasTypingIndicatorVisibleBefore && isScrolledToBottom
+
showScrollToBottomButtonIfApplicable()
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)
@@ -1069,11 +1110,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private fun updatePlaceholder() {
val recipient = viewModel.recipient
?: return Log.w("Loki", "recipient was null in placeholder update")
+ val blindedRecipient = viewModel.blindedRecipient
val binding = binding ?: return
val openGroup = viewModel.openGroup
+
val (textResource, insertParam) = when {
recipient.isLocalNumber -> R.string.activity_conversation_empty_state_note_to_self to null
openGroup != null && !openGroup.canWrite -> R.string.activity_conversation_empty_state_read_only to recipient.toShortString()
+ blindedRecipient?.blocksCommunityMessageRequests == true -> R.string.activity_conversation_empty_state_blocks_community_requests to recipient.toShortString()
else -> R.string.activity_conversation_empty_state_default to recipient.toShortString()
}
val showPlaceholder = adapter.itemCount == 0
@@ -1110,33 +1154,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
@@ -1169,7 +1193,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
override fun copyOpenGroupUrl(thread: Recipient) {
- if (!thread.isOpenGroupRecipient) { return }
+ if (!thread.isCommunityRecipient) { return }
val threadId = threadDb.getThreadIdIfExistsFor(thread) ?: return
val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return
@@ -1180,20 +1204,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() {
@@ -1235,6 +1252,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// `position` is the adapter position; not the visual position
private fun handleSwipeToReply(message: MessageRecord) {
+ if (message.isOpenGroupInvitation) return
val recipient = viewModel.recipient ?: return
binding?.inputBar?.draftQuote(recipient, message, glide)
}
@@ -1314,6 +1332,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
sendEmojiRemoval(emoji, messageRecord)
} else {
sendEmojiReaction(emoji, messageRecord)
+ RecentEmojiPageModel.onCodePointSelected(emoji) // Save to recently used reaction emojis
}
}
@@ -1340,7 +1359,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} else originalMessage.individualRecipient.address
// Send it
reactionMessage.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, true)
- if (recipient.isOpenGroupRecipient) {
+ if (recipient.isCommunityRecipient) {
val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return
viewModel.openGroup?.let {
OpenGroupApi.addReaction(it.room, it.server, messageServerId, emoji)
@@ -1364,7 +1383,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} else originalMessage.individualRecipient.address
message.reaction = Reaction.from(originalMessage.timestamp, originalAuthor.serialize(), emoji, false)
- if (recipient.isOpenGroupRecipient) {
+ if (recipient.isCommunityRecipient) {
val messageServerId = lokiMessageDb.getServerID(originalMessage.id, !originalMessage.isMms) ?: return
viewModel.openGroup?.let {
OpenGroupApi.deleteReaction(it.room, it.server, messageServerId, emoji)
@@ -1538,8 +1557,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (indexInAdapter < 0 || indexInAdapter >= adapter.itemCount) { return }
val viewHolder = binding?.conversationRecyclerView?.findViewHolderForAdapterPosition(indexInAdapter) as? ConversationAdapter.VisibleMessageViewHolder ?: return
- val visibleMessageView = ViewVisibleMessageBinding.bind(viewHolder.view).visibleMessageView
- visibleMessageView.playVoiceMessage()
+ viewHolder.view.playVoiceMessage()
}
override fun sendMessage() {
@@ -1590,10 +1608,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()
@@ -1611,12 +1633,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 {
@@ -1632,7 +1659,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()
@@ -1697,6 +1728,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 {
@@ -1747,7 +1779,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
sendAttachments(slideDeck.asAttachments(), body)
}
INVITE_CONTACTS -> {
- if (viewModel.recipient?.isOpenGroupRecipient != true) { return }
+ if (viewModel.recipient?.isCommunityRecipient != true) { return }
val extras = intent?.extras ?: return
if (!intent.hasExtra(selectedContactsKey)) { return }
val selectedContacts = extras.getStringArray(selectedContactsKey)!!
@@ -1813,19 +1845,61 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
handleLongPress(messages.first(), 0) //TODO: begin selection mode
}
- 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 }
- if (recipient.isOpenGroupRecipient) {
- val messageCount = 1
+ // The option to "Delete just for me" or "Delete for everyone"
+ private fun showDeleteOrDeleteForEveryoneInCommunityUI(messages: Set) {
+ val bottomSheet = DeleteOptionsBottomSheet()
+ bottomSheet.recipient = viewModel.recipient!!
+ bottomSheet.onDeleteForMeTapped = {
+ messages.forEach(viewModel::deleteLocally)
+ bottomSheet.dismiss()
+ endActionMode()
+ }
+ bottomSheet.onDeleteForEveryoneTapped = {
+ messages.forEach(viewModel::deleteForEveryone)
+ bottomSheet.dismiss()
+ endActionMode()
+ }
+ bottomSheet.onCancelTapped = {
+ bottomSheet.dismiss()
+ endActionMode()
+ }
+ bottomSheet.show(supportFragmentManager, bottomSheet.tag)
+ }
+ private fun showDeleteLocallyUI(messages: Set) {
+ val messageCount = 1
+ showSessionDialog {
+ title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
+ text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
+ button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() }
+ cancelButton(::endActionMode)
+ }
+ }
+
+ // Note: The messages in the provided set may be a single message, or multiple if there are a
+ // group of selected messages.
+ override fun deleteMessages(messages: Set) {
+ val recipient = viewModel.recipient
+ if (recipient == null) {
+ Log.w("ConversationActivityV2", "Asked to delete messages but could not obtain viewModel recipient - aborting.")
+ return
+ }
+
+ val allSentByCurrentUser = messages.all { it.isOutgoing }
+ val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null }
+
+ // If the recipient is a community OR a Note-to-Self then we delete the message for everyone
+ if (recipient.isCommunityRecipient || recipient.isLocalNumber) {
+ val messageCount = 1 // Only used for plurals string
showSessionDialog {
title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
- button(R.string.delete) { messages.forEach(viewModel::deleteForEveryone); endActionMode() }
+ button(R.string.delete) {
+ messages.forEach(viewModel::deleteForEveryone); endActionMode()
+ }
cancelButton { endActionMode() }
}
+ // Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone
} else if (allSentByCurrentUser && allHasHash) {
val bottomSheet = DeleteOptionsBottomSheet()
bottomSheet.recipient = recipient
@@ -1844,13 +1918,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
endActionMode()
}
bottomSheet.show(supportFragmentManager, bottomSheet.tag)
- } else {
+ }
+ else // Finally, if this is a closed group and you are deleting someone else's message(s) then we can only delete locally.
+ {
val messageCount = 1
-
showSessionDialog {
title(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount))
text(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount))
- button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() }
+ button(R.string.delete) {
+ messages.forEach(viewModel::deleteLocally); endActionMode()
+ }
cancelButton(::endActionMode)
}
}
@@ -1869,7 +1946,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
showSessionDialog {
title(R.string.ConversationFragment_ban_selected_user)
text("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.")
- button(R.string.ban) { viewModel.banAndDeleteAll(messages.first().individualRecipient); endActionMode() }
+ button(R.string.ban) { viewModel.banAndDeleteAll(messages.first()); endActionMode() }
cancelButton(::endActionMode)
}
}
@@ -1949,6 +2026,13 @@ 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
+ 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)
@@ -2113,4 +2197,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
+ // AdapterDataObserver implementation to scroll us to the bottom of the ConversationRecyclerView
+ // when we're already near the bottom and we send or receive a message.
+ inner class ConversationAdapterDataObserver(val recyclerView: ConversationRecyclerView, val adapter: ConversationAdapter) : RecyclerView.AdapterDataObserver() {
+ override fun onChanged() {
+ super.onChanged()
+ if (recyclerView.isScrolledToWithin30dpOfBottom) {
+ // Note: The adapter itemCount is zero based - so calling this with the itemCount in
+ // a non-zero based manner scrolls us to the bottom of the last message (including
+ // to the bottom of long messages as required by Jira SES-789 / GitHub 1364).
+ recyclerView.scrollToPosition(adapter.itemCount)
+ }
+ }
+ }
+
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt
index 6013af5ba4..d051d7d93c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt
@@ -5,9 +5,7 @@ import android.content.Intent
import android.database.Cursor
import android.util.SparseArray
import android.util.SparseBooleanArray
-import android.view.LayoutInflater
import android.view.MotionEvent
-import android.view.View
import android.view.ViewGroup
import androidx.annotation.WorkerThread
import androidx.core.util.getOrDefault
@@ -20,7 +18,6 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import network.loki.messenger.R
-import network.loki.messenger.databinding.ViewVisibleMessageBinding
import org.session.libsession.messaging.contacts.Contact
import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView
import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView
@@ -57,6 +54,7 @@ class ConversationAdapter(
private val contactCache = SparseArray(100)
private val contactLoadedCache = SparseBooleanArray(100)
private val lastSeen = AtomicLong(originalLastSeen)
+ private var lastSentMessageId: Long = -1L
init {
lifecycleCoroutineScope.launch(IO) {
@@ -87,7 +85,7 @@ class ConversationAdapter(
}
}
- class VisibleMessageViewHolder(val view: View) : ViewHolder(view)
+ class VisibleMessageViewHolder(val view: VisibleMessageView) : ViewHolder(view)
class ControlMessageViewHolder(val view: ControlMessageView) : ViewHolder(view)
override fun getItemViewType(cursor: Cursor): Int {
@@ -100,7 +98,7 @@ class ConversationAdapter(
@Suppress("NAME_SHADOWING")
val viewType = ViewType.allValues[viewType]
return when (viewType) {
- ViewType.Visible -> VisibleMessageViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.view_visible_message, parent, false))
+ ViewType.Visible -> VisibleMessageViewHolder(VisibleMessageView(context))
ViewType.Control -> ControlMessageViewHolder(ControlMessageView(context))
else -> throw IllegalStateException("Unexpected view type: $viewType.")
}
@@ -112,7 +110,7 @@ class ConversationAdapter(
val messageBefore = getMessageBefore(position, cursor)
when (viewHolder) {
is VisibleMessageViewHolder -> {
- val visibleMessageView = ViewVisibleMessageBinding.bind(viewHolder.view).visibleMessageView
+ val visibleMessageView = viewHolder.view
val isSelected = selectedItems.contains(message)
visibleMessageView.snIsSelected = isSelected
visibleMessageView.indexInAdapter = position
@@ -136,7 +134,8 @@ class ConversationAdapter(
senderId,
lastSeen.get(),
visibleMessageViewDelegate,
- onAttachmentNeedsDownload
+ onAttachmentNeedsDownload,
+ lastSentMessageId
)
if (!message.isDeleted) {
@@ -177,7 +176,7 @@ class ConversationAdapter(
override fun onItemViewRecycled(viewHolder: ViewHolder?) {
when (viewHolder) {
- is VisibleMessageViewHolder -> viewHolder.view.findViewById(R.id.visibleMessageView).recycle()
+ is VisibleMessageViewHolder -> viewHolder.view.recycle()
is ControlMessageViewHolder -> viewHolder.view.recycle()
}
super.onItemViewRecycled(viewHolder)
@@ -207,6 +206,7 @@ class ConversationAdapter(
override fun changeCursor(cursor: Cursor?) {
super.changeCursor(cursor)
+
val toRemove = mutableSetOf()
val toDeselect = mutableSetOf>()
for (selected in selectedItems) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt
index 4692bf7862..2ac613bf66 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationLoader.kt
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import android.database.Cursor
+import org.session.libsession.messaging.MessagingModuleConfiguration
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.AbstractCursorLoader
@@ -12,6 +13,7 @@ class ConversationLoader(
) : AbstractCursorLoader(context) {
override fun getCursor(): Cursor {
+ MessagingModuleConfiguration.shared.lastSentTimestampCache.refresh(threadID)
return DatabaseComponent.get(context).mmsSmsDatabase().getConversation(threadID, reverse)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.java
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..af754a300a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt
@@ -0,0 +1,719 @@
+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.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 && !message.isOpenGroupInvitation) {
+ 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.isCommunityRecipient && 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 13736974b1..1a036eee11 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,36 +1,44 @@
package org.thoughtcrime.securesms.conversation.v2
-import android.content.ContentResolver
+import android.content.Context
+
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
-import app.cash.copper.flow.observeQuery
+
import com.goterl.lazysodium.utils.KeyPair
+
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
+
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
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
-import org.thoughtcrime.securesms.database.DatabaseContentProviders
+import org.thoughtcrime.securesms.audio.AudioSlidePlayer
+
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord
+import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.repository.ConversationRepository
+
import java.util.UUID
class ConversationViewModel(
val threadId: Long,
val edKeyPair: KeyPair?,
- private val contentResolver: ContentResolver,
private val repository: ConversationRepository,
private val storage: Storage
) : ViewModel() {
@@ -44,9 +52,21 @@ class ConversationViewModel(
private var _recipient: RetrieveOnce = RetrieveOnce {
repository.maybeGetRecipientForThreadId(threadId)
}
+ val expirationConfiguration: ExpirationConfiguration?
+ get() = storage.getExpirationConfiguration(threadId)
+
val recipient: Recipient?
get() = _recipient.value
+ val blindedRecipient: Recipient?
+ get() = _recipient.value?.let { recipient ->
+ when {
+ recipient.isOpenGroupOutboxRecipient -> recipient
+ recipient.isOpenGroupInboxRecipient -> repository.maybeGetBlindedRecipient(recipient)
+ else -> null
+ }
+ }
+
private var _openGroup: RetrieveOnce = RetrieveOnce {
storage.getOpenGroup(threadId)
}
@@ -62,18 +82,35 @@ class ConversationViewModel(
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
}
+ val isMessageRequestThread : Boolean
+ get() {
+ val recipient = recipient ?: return false
+ return !recipient.isLocalNumber && !recipient.isGroupRecipient && !recipient.isApproved
+ }
+
+ val canReactToMessages: Boolean
+ // allow reactions if the open group is null (normal conversations) or the open group's capabilities include reactions
+ get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities)
+
+
init {
viewModelScope.launch(Dispatchers.IO) {
- contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId))
- .collect {
- val recipientExists = storage.getRecipientForThread(threadId) != null
- if (!recipientExists && _uiState.value.conversationExists) {
+ repository.recipientUpdateFlow(threadId)
+ .collect { recipient ->
+ if (recipient == null && _uiState.value.conversationExists) {
_uiState.update { it.copy(conversationExists = false) }
}
}
}
}
+ override fun onCleared() {
+ super.onCleared()
+
+ // Stop all voice message when exiting this page
+ AudioSlidePlayer.stopAll()
+ }
+
fun saveDraft(text: String) {
GlobalScope.launch(Dispatchers.IO) {
repository.saveDraft(threadId, text)
@@ -113,19 +150,36 @@ class ConversationViewModel(
}
fun deleteLocally(message: MessageRecord) {
+ stopPlayingAudioMessage(message)
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for delete locally action")
repository.deleteLocally(recipient, message)
}
+ /**
+ * Stops audio player if its current playing is the one given in the message.
+ */
+ private fun stopPlayingAudioMessage(message: MessageRecord) {
+ val mmsMessage = message as? MmsMessageRecord ?: return
+ val audioSlide = mmsMessage.slideDeck.audioSlide ?: return
+ AudioSlidePlayer.getInstance()?.takeIf { it.audioSlide == audioSlide }?.stop()
+ }
+
fun setRecipientApproved() {
val recipient = recipient ?: return Log.w("Loki", "Recipient was null for set approved action")
repository.setApproved(recipient, true)
}
fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch {
- val recipient = recipient ?: return@launch
+ val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.")
+ stopPlayingAudioMessage(message)
+
repository.deleteForEveryone(threadId, recipient, message)
+ .onSuccess {
+ Log.d("Loki", "Deleted message ${message.id} ")
+ stopPlayingAudioMessage(message)
+ }
.onFailure {
+ Log.w("Loki", "FAILED TO delete message ${message.id} ")
showMessage("Couldn't delete message due to error: $it")
}
}
@@ -147,10 +201,15 @@ class ConversationViewModel(
}
}
- fun banAndDeleteAll(recipient: Recipient) = viewModelScope.launch {
- repository.banAndDeleteAll(threadId, recipient)
+ fun banAndDeleteAll(messageRecord: MessageRecord) = viewModelScope.launch {
+
+ repository.banAndDeleteAll(threadId, messageRecord.individualRecipient)
.onSuccess {
+ // At this point the server side messages have been successfully deleted..
showMessage("Successfully banned user and deleted all their messages")
+
+ // ..so we can now delete all their messages in this thread from local storage & remove the views.
+ repository.deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord)
}
.onFailure {
showMessage("Couldn't execute request due to error: $it")
@@ -199,22 +258,28 @@ class ConversationViewModel(
_recipient.updateTo(repository.maybeGetRecipientForThreadId(threadId))
}
+ fun hidesInputBar(): Boolean = openGroup?.canWrite != 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 {
- fun create(threadId: Long, edKeyPair: KeyPair?, contentResolver: ContentResolver): Factory
+ fun create(threadId: Long, edKeyPair: KeyPair?): Factory
}
@Suppress("UNCHECKED_CAST")
class Factory @AssistedInject constructor(
@Assisted private val threadId: Long,
@Assisted private val edKeyPair: KeyPair?,
- @Assisted private val contentResolver: ContentResolver,
private val repository: ConversationRepository,
private val storage: Storage
) : ViewModelProvider.Factory {
override fun create(modelClass: Class): T {
- return ConversationViewModel(threadId, edKeyPair, contentResolver, repository, storage) as T
+ return ConversationViewModel(threadId, edKeyPair, repository, storage) as T
}
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt
index 61732827f3..d5e28fb936 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt
@@ -123,7 +123,7 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
AppTheme {
MessageDetails(
state = state,
- onReply = { setResultAndFinish(ON_REPLY) },
+ onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null,
onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } },
onDelete = { setResultAndFinish(ON_DELETE) },
onClickImage = { viewModel.onClickImage(it) },
@@ -145,7 +145,7 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
@Composable
fun MessageDetails(
state: MessageDetailsState,
- onReply: () -> Unit = {},
+ onReply: (() -> Unit)? = null,
onResend: (() -> Unit)? = null,
onDelete: () -> Unit = {},
onClickImage: (Int) -> Unit = {},
@@ -214,18 +214,20 @@ fun CellMetadata(
@Composable
fun CellButtons(
- onReply: () -> Unit = {},
+ onReply: (() -> Unit)? = null,
onResend: (() -> Unit)? = null,
onDelete: () -> Unit = {},
) {
Cell {
Column {
- ItemButton(
- stringResource(R.string.reply),
- R.drawable.ic_message_details__reply,
- onClick = onReply
- )
- Divider()
+ onReply?.let {
+ ItemButton(
+ stringResource(R.string.reply),
+ R.drawable.ic_message_details__reply,
+ onClick = it
+ )
+ Divider()
+ }
onResend?.let {
ItemButton(
stringResource(R.string.resend),
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..ba153a6b36 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()
@@ -103,7 +117,7 @@ class MessageDetailsViewModel @Inject constructor(
Attachment(slide.details, slide.fileName.orNull(), slide.uri, slide is ImageSlide)
fun onClickImage(index: Int) {
- val state = state.value ?: return
+ val state = state.value
val mmsRecord = state.mmsRecord ?: return
val slide = mmsRecord.slideDeck.slides[index] ?: return
// only open to downloaded images
@@ -144,6 +158,7 @@ data class MessageDetailsState(
val thread: Recipient? = null,
) {
val fromTitle = GetString(R.string.message_details_header__from)
+ val canReply = record?.isOpenGroupInvitation != true
}
data class Attachment(
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 ffdc425c5b..f183cff13f 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
@@ -38,6 +38,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
@@ -99,7 +100,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
@@ -139,53 +140,66 @@ 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()
+ quoteView?.let(binding.inputBarAdditionalContentContainer::removeView)
- // inflate quoteview with typed array here
+ quote = message
+
+ // 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?.let(binding.inputBarAdditionalContentContainer::removeView)
+ 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/input_bar/InputBarRecordingView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt
index ec45b6ca82..6d7281dc47 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarRecordingView.kt
@@ -4,8 +4,6 @@ import android.animation.FloatEvaluator
import android.animation.IntEvaluator
import android.animation.ValueAnimator
import android.content.Context
-import android.os.Handler
-import android.os.Looper
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.ImageView
@@ -14,6 +12,11 @@ import android.widget.RelativeLayout
import android.widget.TextView
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isVisible
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewInputBarRecordingBinding
import org.thoughtcrime.securesms.util.DateUtils
@@ -25,10 +28,10 @@ import java.util.Date
class InputBarRecordingView : RelativeLayout {
private lateinit var binding: ViewInputBarRecordingBinding
private var startTimestamp = 0L
- private val snHandler = Handler(Looper.getMainLooper())
private var dotViewAnimation: ValueAnimator? = null
private var pulseAnimation: ValueAnimator? = null
var delegate: InputBarRecordingViewDelegate? = null
+ private var timerJob: Job? = null
val lockView: LinearLayout
get() = binding.lockView
@@ -50,9 +53,10 @@ class InputBarRecordingView : RelativeLayout {
binding = ViewInputBarRecordingBinding.inflate(LayoutInflater.from(context), this, true)
binding.inputBarMiddleContentContainer.disableClipping()
binding.inputBarCancelButton.setOnClickListener { hide() }
+
}
- fun show() {
+ fun show(scope: CoroutineScope) {
startTimestamp = Date().time
binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_microphone, context.theme))
binding.inputBarCancelButton.alpha = 0.0f
@@ -69,7 +73,7 @@ class InputBarRecordingView : RelativeLayout {
animateDotView()
pulse()
animateLockViewUp()
- updateTimer()
+ startTimer(scope)
}
fun hide() {
@@ -86,6 +90,24 @@ class InputBarRecordingView : RelativeLayout {
}
animation.start()
delegate?.handleVoiceMessageUIHidden()
+ stopTimer()
+ }
+
+ private fun startTimer(scope: CoroutineScope) {
+ timerJob?.cancel()
+ timerJob = scope.launch {
+ while (isActive) {
+ val duration = (Date().time - startTimestamp) / 1000L
+ binding.recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration)
+
+ delay(500)
+ }
+ }
+ }
+
+ private fun stopTimer() {
+ timerJob?.cancel()
+ timerJob = null
}
private fun animateDotView() {
@@ -129,12 +151,6 @@ class InputBarRecordingView : RelativeLayout {
animation.start()
}
- private fun updateTimer() {
- val duration = (Date().time - startTimestamp) / 1000L
- binding.recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration)
- snHandler.postDelayed({ updateTimer() }, 500)
- }
-
fun lock() {
val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
fadeOutAnimation.duration = 250L
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt
index 3746aa52e4..21398c71aa 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt
@@ -65,7 +65,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText
// Copy Session ID
menu.findItem(R.id.menu_context_copy_public_key).isVisible =
- (thread.isGroupRecipient && !thread.isOpenGroupRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
+ (thread.isGroupRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
// Message detail
menu.findItem(R.id.menu_message_details).isVisible = selectedItems.size == 1
// Resend
@@ -77,7 +77,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
&& firstMessage.isMms && (firstMessage as MediaMmsMessageRecord).containsMediaSlide())
// Reply
menu.findItem(R.id.menu_context_reply).isVisible =
- (selectedItems.size == 1 && !firstMessage.isPending && !firstMessage.isFailed)
+ (selectedItems.size == 1 && !firstMessage.isPending && !firstMessage.isFailed && !firstMessage.isOpenGroupInvitation)
}
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu): Boolean {
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..11069937a0 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,31 +46,16 @@ object ConversationMenuHelper {
menu: Menu,
inflater: MenuInflater,
thread: Recipient,
- threadId: Long,
- context: Context,
- onOptionsItemSelected: (MenuItem) -> Unit
+ context: Context
) {
// Prepare
menu.clear()
- val isOpenGroup = thread.isOpenGroupRecipient
+ val isOpenGroup = thread.isCommunityRecipient
// 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) {
@@ -274,7 +253,7 @@ object ConversationMenuHelper {
}
private fun copyOpenGroupUrl(context: Context, thread: Recipient) {
- if (!thread.isOpenGroupRecipient) { return }
+ if (!thread.isCommunityRecipient) { return }
val listener = context as? ConversationMenuListener ?: return
listener.copyOpenGroupUrl(thread)
}
@@ -321,7 +300,7 @@ object ConversationMenuHelper {
}
private fun inviteContacts(context: Context, thread: Recipient) {
- if (!thread.isOpenGroupRecipient) { return }
+ if (!thread.isCommunityRecipient) { return }
val intent = Intent(context, SelectContactsActivity::class.java)
val activity = context as AppCompatActivity
activity.startActivityForResult(intent, ConversationActivityV2.INVITE_CONTACTS)
@@ -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..1177b4afc9 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,79 @@ 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 lateinit var binding: ViewControlMessageBinding
+ private val TAG = "ControlMessageView"
- // 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() }
+ private val binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true)
- private fun initialize() {
- binding = ViewControlMessageBinding.inflate(LayoutInflater.from(context), this, true)
+ constructor(context: Context) : super(context)
+ constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
+ constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
+
+ @Inject lateinit var disappearingMessages: DisappearingMessages
+
+ init {
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 +85,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/QuoteView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt
index 4e91400430..2e0dae6b0d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/QuoteView.kt
@@ -72,7 +72,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
// Author
val author = contactDb.getContactWithSessionID(authorPublicKey)
val localNumber = TextSecurePreferences.getLocalNumber(context)
- val quoteIsLocalUser = localNumber != null && localNumber == author?.sessionID
+ val quoteIsLocalUser = localNumber != null && authorPublicKey == localNumber
val authorDisplayName =
if (quoteIsLocalUser) context.getString(R.string.QuoteView_you)
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..ec26e39986 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
@@ -10,8 +11,10 @@ import android.os.Looper
import android.util.AttributeSet
import android.view.Gravity
import android.view.HapticFeedbackConstants
+import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
+import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.annotation.ColorInt
@@ -21,23 +24,23 @@ 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.isVisible
import androidx.core.view.marginBottom
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
+import network.loki.messenger.databinding.ViewEmojiReactionsBinding
import network.loki.messenger.databinding.ViewVisibleMessageBinding
+import network.loki.messenger.databinding.ViewstubVisibleMessageMarkerContainerBinding
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.LastSentTimestampCache
import org.thoughtcrime.securesms.database.LokiAPIDatabase
import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsDatabase
@@ -61,17 +64,29 @@ import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sqrt
-@AndroidEntryPoint
-class VisibleMessageView : LinearLayout {
+private const val TAG = "VisibleMessageView"
+@AndroidEntryPoint
+class VisibleMessageView : FrameLayout {
+ private var replyDisabled: Boolean = false
@Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var lokiThreadDb: LokiThreadDatabase
@Inject lateinit var lokiApiDb: LokiAPIDatabase
@Inject lateinit var mmsSmsDb: MmsSmsDatabase
@Inject lateinit var smsDb: SmsDatabase
@Inject lateinit var mmsDb: MmsDatabase
+ @Inject lateinit var lastSentTimestampCache: LastSentTimestampCache
+
+ private val binding = ViewVisibleMessageBinding.inflate(LayoutInflater.from(context), this, true)
+
+ private val markerContainerBinding = lazy(LazyThreadSafetyMode.NONE) {
+ ViewstubVisibleMessageMarkerContainerBinding.bind(binding.unreadMarkerContainerStub.inflate())
+ }
+
+ private val emojiReactionsBinding = lazy(LazyThreadSafetyMode.NONE) {
+ ViewEmojiReactionsBinding.bind(binding.emojiReactionsView.inflate())
+ }
- private val binding by lazy { ViewVisibleMessageBinding.bind(this) }
private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate()
private val swipeToReplyIconRect = Rect()
private var dx = 0.0f
@@ -90,7 +105,7 @@ class VisibleMessageView : LinearLayout {
var onPress: ((event: MotionEvent) -> Unit)? = null
var onSwipeToReply: (() -> Unit)? = null
var onLongPress: (() -> Unit)? = null
- val messageContentView: VisibleMessageContentView by lazy { binding.messageContentView.root }
+ val messageContentView: VisibleMessageContentView get() = binding.messageContentView.root
companion object {
const val swipeToReplyThreshold = 64.0f // dp
@@ -104,12 +119,7 @@ class VisibleMessageView : LinearLayout {
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
- override fun onFinishInflate() {
- super.onFinishInflate()
- initialize()
- }
-
- private fun initialize() {
+ init {
isHapticFeedbackEnabled = true
setWillNotDraw(false)
binding.root.disableClipping()
@@ -117,7 +127,11 @@ class VisibleMessageView : LinearLayout {
binding.messageInnerContainer.disableClipping()
binding.messageInnerLayout.disableClipping()
binding.messageContentView.root.disableClipping()
+
+ // Default layout params
+ layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
+
// endregion
// region Updating
@@ -131,15 +145,16 @@ class VisibleMessageView : LinearLayout {
senderSessionID: String,
lastSeen: Long,
delegate: VisibleMessageViewDelegate? = null,
- onAttachmentNeedsDownload: (Long, Long) -> Unit
+ onAttachmentNeedsDownload: (Long, Long) -> Unit,
+ lastSentMessageId: Long
) {
+ replyDisabled = message.isOpenGroupInvitation
val threadID = message.threadId
val thread = threadDb.getRecipientForThreadId(threadID) ?: return
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
@@ -165,7 +180,7 @@ class VisibleMessageView : LinearLayout {
binding.profilePictureView.publicKey = senderSessionID
binding.profilePictureView.update(message.individualRecipient)
binding.profilePictureView.setOnClickListener {
- if (thread.isOpenGroupRecipient) {
+ if (thread.isCommunityRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID)
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) {
// TODO: support v2 soon
@@ -178,7 +193,7 @@ class VisibleMessageView : LinearLayout {
maybeShowUserDetails(senderSessionID, threadID)
}
}
- if (thread.isOpenGroupRecipient) {
+ if (thread.isCommunityRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return
var standardPublicKey = ""
var blindedPublicKey: String? = null
@@ -194,68 +209,43 @@ class VisibleMessageView : LinearLayout {
}
binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected))
val contactContext =
- if (thread.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
+ if (thread.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID
+
// Unread marker
- binding.unreadMarkerContainer.isVisible = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing
+ val shouldShowUnreadMarker = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing
+ if (shouldShowUnreadMarker) {
+ markerContainerBinding.value.root.isVisible = true
+ } else if (markerContainerBinding.isInitialized()) {
+ // Only need to hide the binding when the binding is inflated. (default is gone)
+ markerContainerBinding.value.root.isVisible = false
+ }
+
// Date break
val showDateBreak = isStartOfMessageCluster || snIsSelected
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
+ // Update message status indicator
+ showStatusMessage(message)
- 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)
// Emoji Reactions
- val emojiLayoutParams = binding.emojiReactionsView.root.layoutParams as ConstraintLayout.LayoutParams
- emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f
- binding.emojiReactionsView.root.layoutParams = emojiLayoutParams
-
if (message.reactions.isNotEmpty()) {
val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) }
if (capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) {
- binding.emojiReactionsView.root.setReactions(message.id, message.reactions, message.isOutgoing, delegate)
- binding.emojiReactionsView.root.isVisible = true
- } else {
- binding.emojiReactionsView.root.isVisible = false
+ emojiReactionsBinding.value.root.let { root ->
+ root.setReactions(message.id, message.reactions, message.isOutgoing, delegate)
+ root.isVisible = true
+ (root.layoutParams as ConstraintLayout.LayoutParams).apply {
+ horizontalBias = if (message.isOutgoing) 1f else 0f
+ }
+ }
+ } else if (emojiReactionsBinding.isInitialized()) {
+ emojiReactionsBinding.value.root.isVisible = false
}
}
- else {
- binding.emojiReactionsView.root.isVisible = false
+ else if (emojiReactionsBinding.isInitialized()) {
+ emojiReactionsBinding.value.root.isVisible = false
}
// Populate content view
@@ -274,122 +264,170 @@ 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
- } else {
- previous == null || previous.isUpdate || !DateUtils.isSameHour(current.timestamp, previous.timestamp)
- || current.isOutgoing != previous.isOutgoing
+ // Method to display or hide the status of a message.
+ // Note: Although most commonly used to display the delivery status of a message, we also use the
+ // message status area to display the disappearing messages state - so in this latter case we'll
+ // be displaying the "Sent" and the animating clock icon for outgoing messages or "Read" and the
+ // animated clock icon for incoming messages.
+ private fun showStatusMessage(message: MessageRecord) {
+ // We'll start by hiding everything and then only make visible what we need
+ binding.messageStatusTextView.isVisible = false
+ binding.messageStatusImageView.isVisible = false
+ binding.expirationTimerView.isVisible = false
+
+ // Get details regarding how we should display the message (it's delivery icon, icon tint colour, and
+ // the resource string for what text to display (R.string.delivery_status_sent etc.).
+ val (iconID, iconColor, textId) = getMessageStatusInfo(message)
+
+ // If we get any nulls then a message isn't one with a state that we care about (i.e., control messages
+ // etc.) - so bail. See: `DisplayRecord.is` for the full suite of message state methods.
+ // Also: We set all delivery status elements visibility to false just to make sure we don't display any
+ // stale data.
+ if (textId == null) return
+
+ binding.messageInnerLayout.modifyLayoutParams {
+ gravity = if (message.isOutgoing) Gravity.END else Gravity.START
+ }
+ binding.statusContainer.modifyLayoutParams {
+ horizontalBias = if (message.isOutgoing) 1f else 0f
+ }
+
+ // If the message is incoming AND it is not scheduled to disappear then don't show any status or timer details
+ val scheduledToDisappear = message.expiresIn > 0
+ if (message.isIncoming && !scheduledToDisappear) return
+
+ // Set text & icons as appropriate for the message state. Note: Possible message states we care
+ // about are: isFailed, isSyncFailed, isPending, isSyncing, isResyncing, isRead, and isSent.
+ 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)
+
+ // Potential options at this point are that the message is:
+ // i.) incoming AND scheduled to disappear.
+ // ii.) outgoing but NOT scheduled to disappear, or
+ // iii.) outgoing AND scheduled to disappear.
+
+ // ----- Case i..) Message is incoming and scheduled to disappear -----
+ if (message.isIncoming && scheduledToDisappear) {
+ // Display the status ('Read') and the show the timer only (no delivery icon)
+ binding.messageStatusTextView.isVisible = true
+ binding.expirationTimerView.isVisible = true
+ binding.expirationTimerView.bringToFront()
+ updateExpirationTimer(message)
+ return
+ }
+
+ // --- If we got here then we know the message is outgoing ---
+
+ // ----- Case ii.) Message is outgoing but NOT scheduled to disappear -----
+ if (!scheduledToDisappear) {
+ // If this isn't a disappearing message then we never show the timer
+
+ // If the message has NOT been successfully sent then always show the delivery status text and icon..
+ val neitherSentNorRead = !(message.isSent || message.isRead)
+ if (neitherSentNorRead) {
+ binding.messageStatusTextView.isVisible = true
+ binding.messageStatusImageView.isVisible = true
+ } else {
+ // ..but if the message HAS been successfully sent or read then only display the delivery status
+ // text and image if this is the last sent message.
+ val lastSentTimestamp = lastSentTimestampCache.getTimestamp(message.threadId)
+ val isLastSent = lastSentTimestamp == message.timestamp
+ binding.messageStatusTextView.isVisible = isLastSent
+ binding.messageStatusImageView.isVisible = isLastSent
+ if (isLastSent) { binding.messageStatusImageView.bringToFront() }
+ }
+ }
+ else // ----- Case iii.) Message is outgoing AND scheduled to disappear -----
+ {
+ // Always display the delivery status text on all outgoing disappearing messages
+ binding.messageStatusTextView.isVisible = true
+
+ // If the message is sent or has been read..
+ val sentOrRead = message.isSent || message.isRead
+ if (sentOrRead) {
+ // ..then display the timer icon for this disappearing message (but keep the message status icon hidden)
+ binding.expirationTimerView.isVisible = true
+ binding.expirationTimerView.bringToFront()
+ updateExpirationTimer(message)
+ } else {
+ // If the message has NOT been sent or read (or it has failed) then show the delivery status icon rather than the timer icon
+ binding.messageStatusImageView.isVisible = true
+ binding.messageStatusImageView.bringToFront()
+ }
}
}
- 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 {
+ private fun getMessageStatusInfo(message: MessageRecord): MessageStatusInfo = when {
message.isFailed ->
- MessageStatusInfo(
- R.drawable.ic_delivery_status_failed,
+ 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 ->
+ message.isSyncing || 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.getColorFromAttr(R.attr.message_status_color),
+ R.string.delivery_status_sending // We COULD tell the user that we're `syncing` (R.string.delivery_status_syncing) but it will likely make more sense to them if we say "Sending"
)
- message.isRead ->
+ message.isRead || message.isIncoming ->
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 ->
+ message.isSent ->
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
)
+ else -> {
+ // The message isn't one we care about for message statuses we display to the user (i.e.,
+ // control messages etc. - see the `DisplayRecord.is` suite of methods for options).
+ MessageStatusInfo(null, null, null)
+ }
}
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 +464,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) {
@@ -453,6 +492,7 @@ class VisibleMessageView : LinearLayout {
} else {
longPressCallback?.let { gestureHandler.removeCallbacks(it) }
}
+ if (replyDisabled) return
if (translationX > 0) { return } // Only allow swipes to the left
// The idea here is to asymptotically approach a maximum drag distance
val damping = 50.0f
@@ -526,14 +566,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/messages/VoiceMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt
index 2b829af152..06a5168a99 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VoiceMessageView.kt
@@ -68,7 +68,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener {
return
}
- val player = AudioSlidePlayer.createFor(context, audio, this)
+ val player = AudioSlidePlayer.createFor(context.applicationContext, audio, this)
this.player = player
(audio.asAttachment() as? DatabaseAttachment)?.let { attachment ->
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java
index 088685241c..76b95d7b17 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/AttachmentManager.java
@@ -241,7 +241,21 @@ public class AttachmentManager {
}
public static void selectDocument(Activity activity, int requestCode) {
- selectMediaType(activity, "*/*", null, requestCode);
+ Permissions.PermissionsBuilder builder = Permissions.with(activity);
+
+ // The READ_EXTERNAL_STORAGE permission is deprecated (and will AUTO-FAIL if requested!) on
+ // Android 13 and above (API 33 - 'Tiramisu') we must ask for READ_MEDIA_VIDEO/IMAGES/AUDIO instead.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO)
+ .request(Manifest.permission.READ_MEDIA_IMAGES)
+ .request(Manifest.permission.READ_MEDIA_AUDIO);
+ } else {
+ builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE);
+ }
+ builder.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio))
+ .withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24)
+ .onAllGranted(() -> selectMediaType(activity, "*/*", null, requestCode)) // Note: We can use startActivityForResult w/ the ACTION_OPEN_DOCUMENT or ACTION_OPEN_DOCUMENT_TREE intent if we need to modernise this.
+ .execute();
}
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt
index 1ba4a0c3e5..cb9a19ffc1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/MentionUtilities.kt
@@ -1,21 +1,28 @@
package org.thoughtcrime.securesms.conversation.v2.utilities
+import android.app.Application
import android.content.Context
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableString
+import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.Range
+import androidx.appcompat.widget.ThemeUtils
import androidx.core.content.res.ResourcesCompat
import network.loki.messenger.R
import nl.komponents.kovenant.combine.Tuple2
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences
+import org.session.libsession.utilities.ThemeUtil
+import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.getAccentColor
+import org.thoughtcrime.securesms.util.getColorResourceIdFromAttr
+import org.thoughtcrime.securesms.util.getMessageTextColourAttr
import java.util.regex.Pattern
object MentionUtilities {
@@ -58,15 +65,37 @@ object MentionUtilities {
}
}
val result = SpannableString(text)
- val isLightMode = UiModeUtilities.isDayUiMode(context)
- val color = if (isOutgoingMessage) {
- ResourcesCompat.getColor(context.resources, if (isLightMode) R.color.white else R.color.black, context.theme)
- } else {
- context.getAccentColor()
+
+ var mentionTextColour: Int? = null
+ // In dark themes..
+ if (ThemeUtil.isDarkTheme(context)) {
+ // ..we use the standard outgoing message colour for outgoing messages..
+ if (isOutgoingMessage) {
+ val mentionTextColourAttributeId = getMessageTextColourAttr(true)
+ val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId)
+ mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme)
+ }
+ else // ..but we use the accent colour for incoming messages (i.e., someone mentioning us)..
+ {
+ mentionTextColour = context.getAccentColor()
+ }
}
+ else // ..while in light themes we always just use the incoming or outgoing message text colour for mentions.
+ {
+ val mentionTextColourAttributeId = getMessageTextColourAttr(isOutgoingMessage)
+ val mentionTextColourResourceId = getColorResourceIdFromAttr(context, mentionTextColourAttributeId)
+ mentionTextColour = ResourcesCompat.getColor(context.resources, mentionTextColourResourceId, context.theme)
+ }
+
for (mention in mentions) {
- result.setSpan(ForegroundColorSpan(color), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ result.setSpan(ForegroundColorSpan(mentionTextColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
result.setSpan(StyleSpan(Typeface.BOLD), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+
+ // If we're using a light theme then we change the background colour of the mention to be the accent colour
+ if (ThemeUtil.isLightTheme(context)) {
+ val backgroundColour = context.getAccentColor();
+ result.setSpan(BackgroundColorSpan(backgroundColour), mention.first.lower, mention.first.upper, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
}
return result
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt
index e01a75b30c..c1d6987904 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt
@@ -40,18 +40,16 @@ object ResendMessageUtilities {
message.recipient = messageRecord.recipient.address.serialize()
}
message.threadID = messageRecord.threadId
- if (messageRecord.isMms) {
- val mmsMessageRecord = messageRecord as MmsMessageRecord
- if (mmsMessageRecord.linkPreviews.isNotEmpty()) {
- message.linkPreview = LinkPreview.from(mmsMessageRecord.linkPreviews[0])
- }
- if (mmsMessageRecord.quote != null) {
- message.quote = Quote.from(mmsMessageRecord.quote!!.quoteModel)
- if (userBlindedKey != null && messageRecord.quote!!.author.serialize() == TextSecurePreferences.getLocalNumber(context)) {
- message.quote!!.publicKey = userBlindedKey
+ if (messageRecord.isMms && messageRecord is MmsMessageRecord) {
+ messageRecord.linkPreviews.firstOrNull()?.let { message.linkPreview = LinkPreview.from(it) }
+ messageRecord.quote?.quoteModel?.let {
+ message.quote = Quote.from(it)?.apply {
+ if (userBlindedKey != null && publicKey == TextSecurePreferences.getLocalNumber(context)) {
+ publicKey = userBlindedKey
+ }
}
}
- message.addSignalAttachments(mmsMessageRecord.slideDeck.asAttachments())
+ message.addSignalAttachments(messageRecord.slideDeck.asAttachments())
}
val sentTimestamp = message.sentTimestamp
val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey()
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/crypto/IdentityKeyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java
index a5333ef5d4..62aaf58f1a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/IdentityKeyUtil.java
@@ -52,6 +52,7 @@ public class IdentityKeyUtil {
public static final String IDENTITY_PRIVATE_KEY_PREF = "pref_identity_private_v3";
public static final String ED25519_PUBLIC_KEY = "pref_ed25519_public_key";
public static final String ED25519_SECRET_KEY = "pref_ed25519_secret_key";
+ public static final String NOTIFICATION_KEY = "pref_notification_key";
public static final String LOKI_SEED = "loki_seed";
public static final String HAS_MIGRATED_KEY = "has_migrated_keys";
diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt
index 652732f081..f4887e1adb 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyPairUtilities.kt
@@ -1,10 +1,9 @@
package org.thoughtcrime.securesms.crypto
import android.content.Context
-import com.goterl.lazysodium.LazySodiumAndroid
-import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.utils.Key
import com.goterl.lazysodium.utils.KeyPair
+import org.session.libsession.messaging.utilities.SodiumUtilities.sodium
import org.session.libsignal.crypto.ecc.DjbECPrivateKey
import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.session.libsignal.crypto.ecc.ECKeyPair
@@ -13,8 +12,6 @@ import org.session.libsignal.utilities.Hex
object KeyPairUtilities {
- private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) }
-
fun generate(): KeyPairGenerationResult {
val seed = sodium.randomBytesBuf(16)
try {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ConversationNotificationDebouncer.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ConversationNotificationDebouncer.kt
index 84e1b9b20a..5620814190 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/ConversationNotificationDebouncer.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/ConversationNotificationDebouncer.kt
@@ -5,9 +5,9 @@ import android.content.Context
import org.session.libsession.utilities.Debouncer
import org.thoughtcrime.securesms.ApplicationContext
-class ConversationNotificationDebouncer(private val context: Context) {
+class ConversationNotificationDebouncer(private val context: ApplicationContext) {
private val threadIDs = mutableSetOf()
- private val handler = (context.applicationContext as ApplicationContext).conversationListNotificationHandler
+ private val handler = context.conversationListNotificationHandler
private val debouncer = Debouncer(handler, 100)
companion object {
@@ -17,20 +17,28 @@ class ConversationNotificationDebouncer(private val context: Context) {
@Synchronized
fun get(context: Context): ConversationNotificationDebouncer {
if (::shared.isInitialized) { return shared }
- shared = ConversationNotificationDebouncer(context)
+ shared = ConversationNotificationDebouncer(context.applicationContext as ApplicationContext)
return shared
}
}
fun notify(threadID: Long) {
- threadIDs.add(threadID)
+ synchronized(threadIDs) {
+ threadIDs.add(threadID)
+ }
+
debouncer.publish { publish() }
}
private fun publish() {
- for (threadID in threadIDs.toList()) {
+ val toNotify = synchronized(threadIDs) {
+ val copy = threadIDs.toList()
+ threadIDs.clear()
+ copy
+ }
+
+ for (threadID in toNotify) {
context.contentResolver.notifyChange(DatabaseContentProviders.Conversation.getUriForThread(threadID), null)
}
- threadIDs.clear()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java
index b6b224589e..e1879d5230 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java
@@ -26,6 +26,7 @@ import androidx.annotation.NonNull;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.session.libsession.utilities.WindowDebouncer;
+import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
@@ -77,11 +78,11 @@ public abstract class Database {
notifyConversationListListeners();
}
- protected void setNotifyConverationListeners(Cursor cursor, long threadId) {
+ protected void setNotifyConversationListeners(Cursor cursor, long threadId) {
cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getUriForThread(threadId));
}
- protected void setNotifyConverationListListeners(Cursor cursor) {
+ protected void setNotifyConversationListListeners(Cursor cursor) {
cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.ConversationList.CONTENT_URI);
}
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..013bbf5cb5
--- /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.COMMUNITY_INBOX_PREFIX
+import org.session.libsession.utilities.GroupUtil.COMMUNITY_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 '$COMMUNITY_PREFIX%'
+ AND ${ThreadDatabase.TABLE_NAME}.${ThreadDatabase.ADDRESS} NOT LIKE '$COMMUNITY_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/FastCursorRecyclerViewAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/database/FastCursorRecyclerViewAdapter.java
deleted file mode 100644
index 4dfe6a20b5..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/database/FastCursorRecyclerViewAdapter.java
+++ /dev/null
@@ -1,110 +0,0 @@
-package org.thoughtcrime.securesms.database;
-
-
-import android.content.Context;
-import android.database.Cursor;
-import androidx.annotation.NonNull;
-import androidx.recyclerview.widget.RecyclerView;
-
-import java.util.Iterator;
-import java.util.LinkedList;
-import java.util.List;
-
-public abstract class FastCursorRecyclerViewAdapter
- extends CursorRecyclerViewAdapter
-{
- private static final String TAG = FastCursorRecyclerViewAdapter.class.getSimpleName();
-
- private final LinkedList fastRecords = new LinkedList<>();
- private final List releasedRecordIds = new LinkedList<>();
-
- protected FastCursorRecyclerViewAdapter(Context context, Cursor cursor) {
- super(context, cursor);
- }
-
- public void addFastRecord(@NonNull T record) {
- fastRecords.addFirst(record);
- notifyDataSetChanged();
- }
-
- public void releaseFastRecord(long id) {
- synchronized (releasedRecordIds) {
- releasedRecordIds.add(id);
- }
- }
-
- protected void cleanFastRecords() {
- synchronized (releasedRecordIds) {
- Iterator releaseIdIterator = releasedRecordIds.iterator();
-
- while (releaseIdIterator.hasNext()) {
- long releasedId = releaseIdIterator.next();
- Iterator fastRecordIterator = fastRecords.iterator();
-
- while (fastRecordIterator.hasNext()) {
- if (isRecordForId(fastRecordIterator.next(), releasedId)) {
- fastRecordIterator.remove();
- releaseIdIterator.remove();
- break;
- }
- }
- }
- }
- }
-
- protected abstract T getRecordFromCursor(@NonNull Cursor cursor);
- protected abstract void onBindItemViewHolder(VH viewHolder, @NonNull T record);
- protected abstract long getItemId(@NonNull T record);
- protected abstract int getItemViewType(@NonNull T record);
- protected abstract boolean isRecordForId(@NonNull T record, long id);
-
- @Override
- public int getItemViewType(@NonNull Cursor cursor) {
- T record = getRecordFromCursor(cursor);
- return getItemViewType(record);
- }
-
- @Override
- public void onBindItemViewHolder(VH viewHolder, @NonNull Cursor cursor) {
- T record = getRecordFromCursor(cursor);
- onBindItemViewHolder(viewHolder, record);
- }
-
- @Override
- public void onBindFastAccessItemViewHolder(VH viewHolder, int position) {
- int calculatedPosition = getCalculatedPosition(position);
- onBindItemViewHolder(viewHolder, fastRecords.get(calculatedPosition));
- }
-
- @Override
- protected int getFastAccessSize() {
- return fastRecords.size();
- }
-
- protected T getRecordForPositionOrThrow(int position) {
- if (isFastAccessPosition(position)) {
- return fastRecords.get(getCalculatedPosition(position));
- } else {
- Cursor cursor = getCursorAtPositionOrThrow(position);
- return getRecordFromCursor(cursor);
- }
- }
-
- protected int getFastAccessItemViewType(int position) {
- return getItemViewType(fastRecords.get(getCalculatedPosition(position)));
- }
-
- protected boolean isFastAccessPosition(int position) {
- position = getCalculatedPosition(position);
- return position >= 0 && position < fastRecords.size();
- }
-
- protected long getFastAccessItemId(int position) {
- return getItemId(fastRecords.get(getCalculatedPosition(position)));
- }
-
- private int getCalculatedPosition(int position) {
- return hasHeaderView() ? position - 1 : position;
- }
-
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LastSentTimestampCache.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LastSentTimestampCache.kt
new file mode 100644
index 0000000000..46ada7aa9a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/LastSentTimestampCache.kt
@@ -0,0 +1,38 @@
+package org.thoughtcrime.securesms.database
+
+import org.session.libsession.messaging.LastSentTimestampCache
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class LastSentTimestampCache @Inject constructor(
+ val mmsSmsDatabase: MmsSmsDatabase
+): LastSentTimestampCache {
+
+ private val map = mutableMapOf()
+
+ @Synchronized
+ override fun getTimestamp(threadId: Long): Long? = map[threadId]
+
+ @Synchronized
+ override fun submitTimestamp(threadId: Long, timestamp: Long) {
+ if (map[threadId]?.let { timestamp <= it } == true) return
+
+ map[threadId] = timestamp
+ }
+
+ @Synchronized
+ override fun delete(threadId: Long, timestamps: List) {
+ if (map[threadId]?.let { it !in timestamps } == true) return
+ map.remove(threadId)
+ refresh(threadId)
+ }
+
+ @Synchronized
+ override fun refresh(threadId: Long) {
+ if (map[threadId]?.let { it > 0 } == true) return
+ val lastOutgoingTimestamp = mmsSmsDatabase.getLastOutgoingTimestamp(threadId)
+ if (lastOutgoingTimestamp <= 0) return
+ map[threadId] = lastOutgoingTimestamp
+ }
+}
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..18dd42818d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt
@@ -4,6 +4,7 @@ import android.content.ContentValues
import android.content.Context
import net.zetetic.database.sqlcipher.SQLiteDatabase.CONFLICT_REPLACE
import org.session.libsignal.database.LokiMessageDatabaseProtocol
+import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), LokiMessageDatabaseProtocol {
@@ -13,6 +14,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 +35,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
@@ -66,7 +73,12 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab
"${Companion.messageID} = ? AND $messageType = ?",
arrayOf(messageID.toString(), (if (isSms) SMS_TYPE else MMS_TYPE).toString())) { cursor ->
cursor.getInt(serverID).toLong()
- } ?: return
+ }
+
+ if (serverID == null) {
+ Log.w(this::class.simpleName, "Could not get server ID to delete message with ID: $messageID")
+ return
+ }
database.beginTransaction()
@@ -201,52 +213,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/MediaDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java
index 1b273de929..63db0c66ba 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java
@@ -68,7 +68,7 @@ public class MediaDatabase extends Database {
public Cursor getGalleryMediaForThread(long threadId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = database.rawQuery(GALLERY_MEDIA_QUERY, new String[]{threadId+""});
- setNotifyConverationListeners(cursor, threadId);
+ setNotifyConversationListeners(cursor, threadId);
return cursor;
}
@@ -83,7 +83,7 @@ public class MediaDatabase extends Database {
public Cursor getDocumentMediaForThread(long threadId) {
SQLiteDatabase database = databaseHelper.getReadableDatabase();
Cursor cursor = database.rawQuery(DOCUMENT_MEDIA_QUERY, new String[]{threadId+""});
- setNotifyConverationListeners(cursor, threadId);
+ setNotifyConversationListeners(cursor, threadId);
return cursor;
}
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..5648cdace1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt
@@ -21,9 +21,11 @@ import android.content.Context
import android.database.Cursor
import com.annimon.stream.Stream
import com.google.android.mms.pdu_alt.PduHeaders
+import org.apache.commons.lang3.StringUtils
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
@@ -212,7 +214,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
fun getMessage(messageId: Long): Cursor {
val cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString()))
- setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId))
+ setNotifyConversationListeners(cursor, getThreadIdForMessage(messageId))
return cursor
}
@@ -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,8 +627,10 @@ 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) {
+ Log.w(TAG, "insertSecureDecryptedMessageOutbox believes the MmsDatabase insertion failed.")
return Optional.absent()
}
markAsSent(messageId, true)
@@ -689,6 +699,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,
@@ -849,8 +860,10 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
*/
private fun deleteMessages(messageIds: Array) {
if (messageIds.isEmpty()) {
+ Log.w(TAG, "No message Ids provided to MmsDatabase.deleteMessages - aborting delete operation!")
return
}
+
// don't need thread IDs
val queryBuilder = StringBuilder()
for (i in messageIds.indices) {
@@ -873,6 +886,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
notifyStickerPackListeners()
}
+ // Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?"
+ // - it is "Was the thread deleted because removing that message resulted in an empty thread"!
override fun deleteMessage(messageId: Long): Boolean {
val threadId = getThreadIdForMessage(messageId)
val attachmentDatabase = get(context).attachmentDatabase()
@@ -889,14 +904,15 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
override fun deleteMessages(messageIds: LongArray, threadId: Long): Boolean {
- val attachmentDatabase = get(context).attachmentDatabase()
- val groupReceiptDatabase = get(context).groupReceiptDatabase()
+ val argsArray = messageIds.map { "?" }
+ val argValues = messageIds.map { it.toString() }.toTypedArray()
- queue(Runnable { attachmentDatabase.deleteAttachmentsForMessages(messageIds) })
- groupReceiptDatabase.deleteRowsForMessages(messageIds)
-
- val database = databaseHelper.writableDatabase
- database!!.delete(TABLE_NAME, ID_IN, arrayOf(messageIds.joinToString(",")))
+ val db = databaseHelper.writableDatabase
+ db.delete(
+ TABLE_NAME,
+ ID + " IN (" + StringUtils.join(argsArray, ',') + ")",
+ argValues
+ )
val threadDeleted = get(context).threadDatabase().update(threadId, false, true)
notifyConversationListeners(threadId)
@@ -1079,8 +1095,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
val whereString = where.substring(0, where.length - 4)
try {
- cursor =
- db!!.query(TABLE_NAME, arrayOf(ID), whereString, null, null, null, null)
+ cursor = db!!.query(TABLE_NAME, arrayOf(ID), whereString, null, null, null, null)
val toDeleteStringMessageIds = mutableListOf()
while (cursor.moveToNext()) {
toDeleteStringMessageIds += cursor.getLong(0).toString()
@@ -1132,13 +1147,9 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
}
- fun readerFor(cursor: Cursor?): Reader {
- return Reader(cursor)
- }
+ fun readerFor(cursor: Cursor?, getQuote: Boolean = true) = Reader(cursor, getQuote)
- fun readerFor(message: OutgoingMediaMessage?, threadId: Long): OutgoingMessageReader {
- return OutgoingMessageReader(message, threadId)
- }
+ fun readerFor(message: OutgoingMediaMessage?, threadId: Long) = OutgoingMessageReader(message, threadId)
fun setQuoteMissing(messageId: Long): Int {
val contentValues = ContentValues()
@@ -1152,6 +1163,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
@@ -1188,7 +1213,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
- inner class Reader(private val cursor: Cursor?) : Closeable {
+ inner class Reader(private val cursor: Cursor?, private val getQuote: Boolean = true) : Closeable {
val next: MessageRecord?
get() = if (cursor == null || !cursor.moveToNext()) null else current
val current: MessageRecord
@@ -1197,7 +1222,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
return if (mmsType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND.toLong()) {
getNotificationMmsMessageRecord(cursor)
} else {
- getMediaMmsMessageRecord(cursor)
+ getMediaMmsMessageRecord(cursor, getQuote)
}
}
@@ -1224,20 +1249,10 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
DELIVERY_RECEIPT_COUNT
)
)
- var readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT))
- val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID))
+ val readReceiptCount = if (isReadReceiptsEnabled(context)) cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) else 0
val hasMention = (cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1)
- if (!isReadReceiptsEnabled(context)) {
- readReceiptCount = 0
- }
- var contentLocationBytes: ByteArray? = null
- var transactionIdBytes: ByteArray? = null
- if (!contentLocation.isNullOrEmpty()) contentLocationBytes = toIsoBytes(
- contentLocation
- )
- if (!transactionId.isNullOrEmpty()) transactionIdBytes = toIsoBytes(
- transactionId
- )
+ val contentLocationBytes: ByteArray? = contentLocation?.takeUnless { it.isEmpty() }?.let(::toIsoBytes)
+ val transactionIdBytes: ByteArray? = transactionId?.takeUnless { it.isEmpty() }?.let(::toIsoBytes)
val slideDeck = SlideDeck(context, MmsNotificationAttachment(status, messageSize))
return NotificationMmsMessageRecord(
id, recipient, recipient,
@@ -1248,7 +1263,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
)
}
- private fun getMediaMmsMessageRecord(cursor: Cursor): MediaMmsMessageRecord {
+ private fun getMediaMmsMessageRecord(cursor: Cursor, getQuote: Boolean): MediaMmsMessageRecord {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID))
val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT))
val dateReceived = cursor.getLong(
@@ -1299,7 +1314,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
.filterNot { o: DatabaseAttachment? -> o in contactAttachments }
.filterNot { o: DatabaseAttachment? -> o in previewAttachments }
)
- val quote = getQuote(cursor)
+ val quote = if (getQuote) getQuote(cursor) else null
val reactions = get(context).reactionDatabase().getReactions(cursor)
return MediaMmsMessageRecord(
id, recipient, recipient,
@@ -1352,7 +1367,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
val quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID))
val quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR))
if (quoteId == 0L || quoteAuthor.isNullOrBlank()) return null
- val retrievedQuote = get(context).mmsSmsDatabase().getMessageFor(quoteId, quoteAuthor)
+ val retrievedQuote = get(context).mmsSmsDatabase().getMessageFor(quoteId, quoteAuthor, false)
val quoteText = retrievedQuote?.body
val quoteMissing = retrievedQuote == null
val quoteDeck = (
@@ -1398,7 +1413,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 +1518,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/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java
index 1e1cc50896..e6bc04e364 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java
@@ -9,7 +9,11 @@ public interface MmsSmsColumns {
public static final String THREAD_ID = "thread_id";
public static final String READ = "read";
public static final String BODY = "body";
+
+ // This is the address of the message recipient, which may be a single user, a group, or a community!
+ // It is NOT the address of the sender of any given message!
public static final String ADDRESS = "address";
+
public static final String ADDRESS_DEVICE_ID = "address_device_id";
public static final String DELIVERY_RECEIPT_COUNT = "delivery_receipt_count";
public static final String READ_RECEIPT_COUNT = "read_receipt_count";
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
index 0db4dd00e5..b737be855e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
@@ -30,6 +30,7 @@ import net.zetetic.database.sqlcipher.SQLiteQueryBuilder;
import org.jetbrains.annotations.NotNull;
import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.Util;
+import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord;
@@ -96,9 +97,13 @@ public class MmsSmsDatabase extends Database {
}
public @Nullable MessageRecord getMessageFor(long timestamp, String serializedAuthor) {
+ return getMessageFor(timestamp, serializedAuthor, true);
+ }
+
+ public @Nullable MessageRecord getMessageFor(long timestamp, String serializedAuthor, boolean getQuote) {
try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) {
- MmsSmsDatabase.Reader reader = readerFor(cursor);
+ MmsSmsDatabase.Reader reader = readerFor(cursor, getQuote);
MessageRecord messageRecord;
boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor);
@@ -115,6 +120,53 @@ public class MmsSmsDatabase extends Database {
return null;
}
+ public @Nullable MessageRecord getSentMessageFor(long timestamp, String serializedAuthor) {
+ // Early exit if the author is not us
+ boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor);
+ if (!isOwnNumber) {
+ Log.i(TAG, "Asked to find sent messages but provided author is not us - returning null.");
+ return null;
+ }
+
+ try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) {
+ MmsSmsDatabase.Reader reader = readerFor(cursor);
+
+ MessageRecord messageRecord;
+ while ((messageRecord = reader.getNext()) != null) {
+ if (messageRecord.isOutgoing())
+ {
+ return messageRecord;
+ }
+ }
+ }
+ Log.i(TAG, "Could not find any message sent from us at provided timestamp - returning null.");
+ return null;
+ }
+
+ public MessageRecord getLastSentMessageRecordFromSender(long threadId, String serializedAuthor) {
+ // Early exit if the author is not us
+ boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor);
+ if (!isOwnNumber) {
+ Log.i(TAG, "Asked to find last sent message but provided author is not us - returning null.");
+ return null;
+ }
+
+ String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC";
+ String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
+
+ // Try everything with resources so that they auto-close on end of scope
+ try (Cursor cursor = queryTables(PROJECTION, selection, order, null)) {
+ try (MmsSmsDatabase.Reader reader = readerFor(cursor)) {
+ MessageRecord messageRecord;
+ while ((messageRecord = reader.getNext()) != null) {
+ if (messageRecord.isOutgoing()) { return messageRecord; }
+ }
+ }
+ }
+ Log.i(TAG, "Could not find last sent message from us in given thread - returning null.");
+ return null;
+ }
+
public @Nullable MessageRecord getMessageFor(long timestamp, Address author) {
return getMessageFor(timestamp, author.serialize());
}
@@ -183,7 +235,7 @@ public class MmsSmsDatabase extends Database {
String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null;
Cursor cursor = queryTables(PROJECTION, selection, order, limitStr);
- setNotifyConverationListeners(cursor, threadId);
+ setNotifyConversationListeners(cursor, threadId);
return cursor;
}
@@ -209,6 +261,79 @@ public class MmsSmsDatabase extends Database {
}
}
+ // Builds up and returns a list of all all the messages sent by this user in the given thread.
+ // Used to do a pass through our local database to remove records when a user has "Ban & Delete"
+ // called on them in a Community.
+ public Set getAllMessageRecordsFromSenderInThread(long threadId, String serializedAuthor) {
+ String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\"";
+ Set identifiedMessages = new HashSet();
+
+ // Try everything with resources so that they auto-close on end of scope
+ try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) {
+ try (MmsSmsDatabase.Reader reader = readerFor(cursor)) {
+ MessageRecord messageRecord;
+ while ((messageRecord = reader.getNext()) != null) {
+ identifiedMessages.add(messageRecord);
+ }
+ }
+ }
+ return identifiedMessages;
+ }
+
+ // Version of the above `getAllMessageRecordsFromSenderInThread` method that returns the message
+ // Ids rather than the set of MessageRecords - currently unused by potentially useful in the future.
+ public Set getAllMessageIdsFromSenderInThread(long threadId, String serializedAuthor) {
+ String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\"";
+
+ Set identifiedMessages = new HashSet();
+
+ // Try everything with resources so that they auto-close on end of scope
+ try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) {
+ try (MmsSmsDatabase.Reader reader = readerFor(cursor)) {
+ MessageRecord messageRecord;
+ while ((messageRecord = reader.getNext()) != null) {
+ identifiedMessages.add(messageRecord.id);
+ }
+ }
+ }
+ return identifiedMessages;
+ }
+
+ public long getLastOutgoingTimestamp(long threadId) {
+ String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC";
+ String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
+
+ // Try everything with resources so that they auto-close on end of scope
+ try (Cursor cursor = queryTables(PROJECTION, selection, order, null)) {
+ try (MmsSmsDatabase.Reader reader = readerFor(cursor)) {
+ MessageRecord messageRecord;
+ long attempts = 0;
+ long maxAttempts = 20;
+ while ((messageRecord = reader.getNext()) != null) {
+ // Note: We rely on the message order to get us the most recent outgoing message - so we
+ // take the first outgoing message we find as the last outgoing message.
+ if (messageRecord.isOutgoing()) return messageRecord.getTimestamp();
+ if (attempts++ > maxAttempts) break;
+ }
+ }
+ }
+ Log.i(TAG, "Could not find last sent message from us - returning -1.");
+ return -1;
+ }
+
+ public long getLastMessageTimestamp(long threadId) {
+ String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC";
+ String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
+
+ try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) {
+ if (cursor.moveToFirst()) {
+ return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_SENT));
+ }
+ }
+
+ return -1;
+ }
+
public Cursor getUnread() {
String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " ASC";
String selection = "(" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + " = 1) AND " + MmsSmsColumns.NOTIFIED + " = 0";
@@ -514,7 +639,11 @@ public class MmsSmsDatabase extends Database {
}
public Reader readerFor(@NonNull Cursor cursor) {
- return new Reader(cursor);
+ return readerFor(cursor, true);
+ }
+
+ public Reader readerFor(@NonNull Cursor cursor, boolean getQuote) {
+ return new Reader(cursor, getQuote);
}
@NotNull
@@ -537,11 +666,13 @@ public class MmsSmsDatabase extends Database {
public class Reader implements Closeable {
private final Cursor cursor;
+ private final boolean getQuote;
private SmsDatabase.Reader smsReader;
private MmsDatabase.Reader mmsReader;
- public Reader(Cursor cursor) {
+ public Reader(Cursor cursor, boolean getQuote) {
this.cursor = cursor;
+ this.getQuote = getQuote;
}
private SmsDatabase.Reader getSmsReader() {
@@ -554,7 +685,7 @@ public class MmsSmsDatabase extends Database {
private MmsDatabase.Reader getMmsReader() {
if (mmsReader == null) {
- mmsReader = DatabaseComponent.get(context).mmsDatabase().readerFor(cursor);
+ mmsReader = DatabaseComponent.get(context).mmsDatabase().readerFor(cursor, getQuote);
}
return mmsReader;
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 b7b8364184..8dbef32017 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java
@@ -1,6 +1,6 @@
package org.thoughtcrime.securesms.database;
-import static org.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX;
+import static org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX;
import android.content.ContentValues;
import android.content.Context;
@@ -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";
@@ -63,13 +64,14 @@ public class RecipientDatabase extends Database {
private static final String FORCE_SMS_SELECTION = "force_sms_selection";
private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none
private static final String WRAPPER_HASH = "wrapper_hash";
+ private static final String BLOCKS_COMMUNITY_MESSAGE_REQUESTS = "blocks_community_message_requests";
private static final String[] RECIPIENT_PROJECTION = new String[] {
BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
UNIDENTIFIED_ACCESS_MODE,
- FORCE_SMS_SELECTION, NOTIFY_TYPE, WRAPPER_HASH
+ FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS
};
static final List TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
@@ -121,27 +123,37 @@ public class RecipientDatabase extends Database {
public static String getUpdateApprovedCommand() {
return "UPDATE "+ TABLE_NAME + " " +
"SET " + APPROVED + " = 1, " + APPROVED_ME + " = 1 " +
- "WHERE " + ADDRESS + " NOT LIKE '" + OPEN_GROUP_PREFIX + "%'";
+ "WHERE " + ADDRESS + " NOT LIKE '" + COMMUNITY_PREFIX + "%'";
}
public static String getUpdateResetApprovedCommand() {
return "UPDATE "+ TABLE_NAME + " " +
"SET " + APPROVED + " = 0, " + APPROVED_ME + " = 0 " +
- "WHERE " + ADDRESS + " NOT LIKE '" + OPEN_GROUP_PREFIX + "%'";
+ "WHERE " + ADDRESS + " NOT LIKE '" + COMMUNITY_PREFIX + "%'";
}
public static String getUpdateApprovedSelectConversations() {
return "UPDATE "+ TABLE_NAME + " SET "+APPROVED+" = 1, "+APPROVED_ME+" = 1 "+
- "WHERE "+ADDRESS+ " NOT LIKE '"+OPEN_GROUP_PREFIX+"%' " +
+ "WHERE "+ADDRESS+ " NOT LIKE '"+ COMMUNITY_PREFIX +"%' " +
"AND ("+ADDRESS+" IN (SELECT "+ThreadDatabase.TABLE_NAME+"."+ThreadDatabase.ADDRESS+" FROM "+ThreadDatabase.TABLE_NAME+" WHERE ("+ThreadDatabase.MESSAGE_COUNT+" != 0) "+
"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;";
}
+ public static String getAddBlocksCommunityMessageRequests() {
+ return "ALTER TABLE "+TABLE_NAME+" "+
+ "ADD COLUMN "+BLOCKS_COMMUNITY_MESSAGE_REQUESTS+" INT DEFAULT 0;";
+ }
+
public static final int NOTIFY_TYPE_ALL = 0;
public static final int NOTIFY_TYPE_MENTIONS = 1;
public static final int NOTIFY_TYPE_NONE = 2;
@@ -177,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));
@@ -197,6 +210,7 @@ public class RecipientDatabase extends Database {
int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1;
String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH));
+ boolean blocksCommunityMessageRequests = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCKS_COMMUNITY_MESSAGE_REQUESTS)) == 1;
MaterialColor color;
byte[] profileKey = null;
@@ -219,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),
@@ -228,7 +243,7 @@ public class RecipientDatabase extends Database {
systemPhoneLabel, systemContactUri,
signalProfileName, signalProfileAvatar, profileSharing,
notificationChannel, Recipient.UnidentifiedAccessMode.fromMode(unidentifiedAccessMode),
- forceSmsSelection, wrapperHash));
+ forceSmsSelection, wrapperHash, blocksCommunityMessageRequests));
}
public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) {
@@ -328,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());
@@ -395,6 +400,14 @@ public class RecipientDatabase extends Database {
notifyRecipientListeners();
}
+ public void setBlocksCommunityMessageRequests(@NonNull Recipient recipient, boolean isBlocked) {
+ ContentValues contentValues = new ContentValues(1);
+ contentValues.put(BLOCKS_COMMUNITY_MESSAGE_REQUESTS, isBlocked ? 1 : 0);
+ updateOrInsert(recipient.getAddress(), contentValues);
+ recipient.resolve().setBlocksCommunityMessageRequests(isBlocked);
+ notifyRecipientListeners();
+ }
+
private void updateOrInsert(Address address, ContentValues contentValues) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
@@ -428,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/SearchDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java
index eac6a5fbc3..106cc86e17 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java
@@ -10,6 +10,7 @@ import com.annimon.stream.Stream;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.session.libsession.utilities.Util;
+
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import java.util.List;
@@ -115,11 +116,9 @@ public class SearchDatabase extends Database {
public Cursor queryMessages(@NonNull String query) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String prefixQuery = adjustQuery(query);
-
int queryLimit = Math.min(query.length()*50,500);
-
Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery, String.valueOf(queryLimit) });
- setNotifyConverationListListeners(cursor);
+ setNotifyConversationListListeners(cursor);
return cursor;
}
@@ -128,7 +127,7 @@ public class SearchDatabase extends Database {
String prefixQuery = adjustQuery(query);
Cursor cursor = db.rawQuery(MESSAGES_FOR_THREAD_QUERY, new String[] { prefixQuery, String.valueOf(threadId), prefixQuery, String.valueOf(threadId) });
- setNotifyConverationListListeners(cursor);
+ setNotifyConversationListListeners(cursor);
return cursor;
}
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..84b9441834 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
@@ -22,15 +22,11 @@ import android.content.Context;
import android.database.Cursor;
import android.text.TextUtils;
import android.util.Pair;
-
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-
import com.annimon.stream.Stream;
-
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteStatement;
-
import org.apache.commons.lang3.StringUtils;
import org.session.libsession.messaging.calls.CallMessageType;
import org.session.libsession.messaging.messages.signal.IncomingGroupMessage;
@@ -51,7 +47,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
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 +86,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 +124,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 +246,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 +358,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 +410,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 +441,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 +456,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 +490,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 +553,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 +597,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);
@@ -604,18 +616,20 @@ public class SmsDatabase extends MessagingDatabase {
public Cursor getMessageCursor(long messageId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = db.query(TABLE_NAME, MESSAGE_PROJECTION, ID_WHERE, new String[] {messageId + ""}, null, null, null);
- setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId));
+ setNotifyConversationListeners(cursor, getThreadIdForMessage(messageId));
return cursor;
}
+ // Caution: The bool returned from `deleteMessage` is NOT "Was the message successfully deleted?"
+ // - it is "Was the thread deleted because removing that message resulted in an empty thread"!
@Override
public boolean deleteMessage(long messageId) {
Log.i("MessageDatabase", "Deleting: " + messageId);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
long threadId = getThreadIdForMessage(messageId);
db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""});
- boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true);
notifyConversationListeners(threadId);
+ boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, false);
return threadDeleted;
}
@@ -629,9 +643,6 @@ public class SmsDatabase extends MessagingDatabase {
argValues[i] = (messageIds[i] + "");
}
- String combinedMessageIdArgss = StringUtils.join(messageIds, ',');
- String combinedMessageIds = StringUtils.join(messageIds, ',');
- Log.i("MessageDatabase", "Deleting: " + combinedMessageIds);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(
TABLE_NAME,
@@ -685,12 +696,7 @@ public class SmsDatabase extends MessagingDatabase {
}
}
- /*package */void deleteThread(long threadId) {
- SQLiteDatabase db = databaseHelper.getWritableDatabase();
- db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""});
- }
-
- /*package*/void deleteMessagesInThreadBeforeDate(long threadId, long date) {
+ void deleteMessagesInThreadBeforeDate(long threadId, long date) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
String where = THREAD_ID + " = ? AND (CASE " + TYPE;
@@ -703,7 +709,12 @@ public class SmsDatabase extends MessagingDatabase {
db.delete(TABLE_NAME, where, new String[] {threadId + ""});
}
- /*package*/ void deleteThreads(Set threadIds) {
+ void deleteThread(long threadId) {
+ SQLiteDatabase db = databaseHelper.getWritableDatabase();
+ db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""});
+ }
+
+ void deleteThreads(Set threadIds) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
String where = "";
@@ -711,23 +722,23 @@ public class SmsDatabase extends MessagingDatabase {
where += THREAD_ID + " = '" + threadId + "' OR ";
}
- where = where.substring(0, where.length() - 4);
+ where = where.substring(0, where.length() - 4); // Remove the final: "' OR "
db.delete(TABLE_NAME, where, null);
}
- /*package */ void deleteAllThreads() {
+ void deleteAllThreads() {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.delete(TABLE_NAME, null, null);
}
- /*package*/ SQLiteDatabase beginTransaction() {
+ SQLiteDatabase beginTransaction() {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
database.beginTransaction();
return database;
}
- /*package*/ void endTransaction(SQLiteDatabase database) {
+ void endTransaction(SQLiteDatabase database) {
database.setTransactionSuccessful();
database.endTransaction();
}
@@ -787,7 +798,7 @@ public class SmsDatabase extends MessagingDatabase {
}
}
- public class Reader {
+ public class Reader implements Closeable {
private final Cursor cursor;
@@ -853,8 +864,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 c77ad1c638..354ec05c46 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
@@ -50,7 +52,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview
-import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
+import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.SessionId
@@ -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
@@ -91,8 +94,13 @@ import org.thoughtcrime.securesms.util.SessionMetaProtocol
import java.security.MessageDigest
import network.loki.messenger.libsession_util.util.Contact as LibSessionContact
-open class Storage(context: Context, helper: SQLCipherOpenHelper, private val configFactory: ConfigFactory) : Database(context, helper), StorageProtocol,
- ThreadDatabase.ConversationThreadUpdateListener {
+private const val TAG = "Storage"
+
+open class Storage(
+ context: Context,
+ helper: SQLCipherOpenHelper,
+ val configFactory: ConfigFactory
+) : Database(context, helper), StorageProtocol, ThreadDatabase.ConversationThreadUpdateListener {
override fun threadCreated(address: Address, threadId: Long) {
val localUserAddress = getUserPublicKey() ?: return
@@ -112,7 +120,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
)
volatile.set(newVolatileParams)
}
- } else if (address.isOpenGroup) {
+ } else if (address.isCommunity) {
// these should be added on the group join / group info fetch
Log.w("Loki", "Thread created called for open group address, not adding any extra information")
}
@@ -143,7 +151,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
volatile.eraseLegacyClosedGroup(sessionId)
groups.eraseLegacyGroup(sessionId)
- } else if (address.isOpenGroup) {
+ } else if (address.isCommunity) {
// these should be removed in the group leave / handling new configs
Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere")
}
@@ -173,7 +181,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)
@@ -190,6 +198,11 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
db.setProfileKey(recipient, newProfileKey)
}
+ override fun setBlocksCommunityMessageRequests(recipient: Recipient, blocksMessageRequests: Boolean) {
+ val db = DatabaseComponent.get(context).recipientDatabase()
+ db.setBlocksCommunityMessageRequests(recipient, blocksMessageRequests)
+ }
+
override fun setUserProfilePicture(newProfilePicture: String?, newProfileKey: ByteArray?) {
val ourRecipient = fromSerialized(getUserPublicKey()!!).let {
Recipient.from(context, it, false)
@@ -243,7 +256,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
// recipient closed group
recipient.isClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize()))
// recipient is open group
- recipient.isOpenGroupRecipient -> {
+ recipient.isCommunityRecipient -> {
val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return
BaseCommunityInfo.parseFullUrl(openGroupJoinUrl)?.let { (base, room, pubKey) ->
config.getOrConstructCommunity(base, room, pubKey)
@@ -313,23 +326,34 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
setRecipientApprovedMe(targetRecipient, true)
}
}
- if (message.threadID == null && !targetRecipient.isOpenGroupRecipient) {
+ if (message.threadID == null && !targetRecipient.isCommunityRecipient) {
// 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) {
@@ -340,12 +364,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)
}
@@ -355,7 +379,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
@@ -418,8 +442,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 {
@@ -430,16 +454,20 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
return configFactory.canPerformChange(variant, publicKey, changeTimestampMs)
}
- fun notifyUpdates(forConfigObject: ConfigBase) {
+ override fun isCheckingCommunityRequests(): Boolean {
+ return configFactory.user?.getCommunityMessageRequests() == true
+ }
+
+ 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)
@@ -465,16 +493,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() {
@@ -494,7 +531,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) {
@@ -511,7 +548,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",
@@ -563,6 +600,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) {
@@ -577,7 +615,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)
@@ -587,11 +624,8 @@ 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
- PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, group.sessionId, localUserPublicKey)
+ PushRegistryV1.subscribeGroup(group.sessionId, publicKey = localUserPublicKey)
// Notify the user
val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId))
threadDb.setDate(threadID, formationTimestamp)
@@ -600,6 +634,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)
+ )
+ }
}
}
@@ -703,10 +743,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(
@@ -726,13 +766,36 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
override fun markAsSent(timestamp: Long, author: String) {
val database = DatabaseComponent.get(context).mmsSmsDatabase()
- val messageRecord = database.getMessageFor(timestamp, author) ?: return
+ val messageRecord = database.getSentMessageFor(timestamp, author)
+ if (messageRecord == null) {
+ Log.w(TAG, "Failed to retrieve local message record in Storage.markAsSent - aborting.")
+ return
+ }
+
if (messageRecord.isMms) {
- val mmsDatabase = DatabaseComponent.get(context).mmsDatabase()
- mmsDatabase.markAsSent(messageRecord.getId(), true)
+ DatabaseComponent.get(context).mmsDatabase().markAsSent(messageRecord.getId(), true)
} else {
- val smsDatabase = DatabaseComponent.get(context).smsDatabase()
- smsDatabase.markAsSent(messageRecord.getId(), true)
+ DatabaseComponent.get(context).smsDatabase().markAsSent(messageRecord.getId(), true)
+ }
+ }
+
+ // Method that marks a message as sent in Communities (only!) - where the server modifies the
+ // message timestamp and as such we cannot use that to identify the local message.
+ override fun markAsSentToCommunity(threadId: Long, messageID: Long) {
+ val database = DatabaseComponent.get(context).mmsSmsDatabase()
+ val message = database.getLastSentMessageRecordFromSender(threadId, TextSecurePreferences.getLocalNumber(context))
+
+ // Ensure we can find the local message..
+ if (message == null) {
+ Log.w(TAG, "Could not find local message in Storage.markAsSentToCommunity - aborting.")
+ return
+ }
+
+ // ..and mark as sent if found.
+ if (message.isMms) {
+ DatabaseComponent.get(context).mmsDatabase().markAsSent(message.getId(), true)
+ } else {
+ DatabaseComponent.get(context).smsDatabase().markAsSent(message.getId(), true)
}
}
@@ -767,7 +830,11 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
override fun markUnidentified(timestamp: Long, author: String) {
val database = DatabaseComponent.get(context).mmsSmsDatabase()
- val messageRecord = database.getMessageFor(timestamp, author) ?: return
+ val messageRecord = database.getMessageFor(timestamp, author)
+ if (messageRecord == null) {
+ Log.w(TAG, "Could not identify message with timestamp: $timestamp from author: $author")
+ return
+ }
if (messageRecord.isMms) {
val mmsDatabase = DatabaseComponent.get(context).mmsDatabase()
mmsDatabase.markUnidentified(messageRecord.getId(), true)
@@ -777,6 +844,26 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
}
}
+ // Method that marks a message as unidentified in Communities (only!) - where the server
+ // modifies the message timestamp and as such we cannot use that to identify the local message.
+ override fun markUnidentifiedInCommunity(threadId: Long, messageId: Long) {
+ val database = DatabaseComponent.get(context).mmsSmsDatabase()
+ val message = database.getLastSentMessageRecordFromSender(threadId, TextSecurePreferences.getLocalNumber(context))
+
+ // Check to ensure the message exists
+ if (message == null) {
+ Log.w(TAG, "Could not find local message in Storage.markUnidentifiedInCommunity - aborting.")
+ return
+ }
+
+ // Mark it as unidentified if we found the message successfully
+ if (message.isMms) {
+ DatabaseComponent.get(context).mmsDatabase().markUnidentified(message.getId(), true)
+ } else {
+ DatabaseComponent.get(context).smsDatabase().markUnidentified(message.getId(), true)
+ }
+ }
+
override fun markAsSentFailed(timestamp: Long, author: String, error: Exception) {
val database = DatabaseComponent.get(context).mmsSmsDatabase()
val messageRecord = database.getMessageFor(timestamp, author) ?: return
@@ -825,8 +912,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? {
@@ -838,9 +925,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)
@@ -851,7 +939,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
@@ -862,8 +950,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
@@ -877,7 +963,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,
@@ -885,7 +971,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)
@@ -917,7 +1003,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()
@@ -925,14 +1011,16 @@ 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
+ if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) {
+ Log.w(TAG, "Bailing from insertOutgoingInfoMessage because we believe the message has already been sent!")
+ return
+ }
val infoMessageID = mmsDB.insertMessageOutbox(infoMessage, threadID, false, null, runThreadUpdate = true)
mmsDB.markAsSent(infoMessageID, true)
}
@@ -987,23 +1075,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)
}
@@ -1126,11 +1197,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)
@@ -1163,13 +1233,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())
}
@@ -1262,7 +1338,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
)
groups.set(newGroupInfo)
- } else if (threadRecipient.isOpenGroupRecipient) {
+ } else if (threadRecipient.isCommunityRecipient) {
val openGroup = getOpenGroup(threadID) ?: return
val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return
val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy (
@@ -1284,34 +1360,40 @@ 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) {
- if (recipient.isLocalNumber) return
- val contacts = configFactory.contacts ?: return
- contacts.upsertContact(recipient.address.serialize()) {
- this.priority = PRIORITY_HIDDEN
- }
- ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
- } else if (recipient.isClosedGroupRecipient) {
- // TODO: handle closed group
- val volatile = configFactory.convoVolatile ?: return
- val groups = configFactory.userGroups ?: return
- val groupID = recipient.address.toGroupString()
- val closedGroup = getGroup(groupID)
- val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
- if (closedGroup != null) {
- groupDB.delete(groupID) // TODO: Should we delete the group? (seems odd to leave it)
- volatile.eraseLegacyClosedGroup(groupPublicKey)
- groups.eraseLegacyGroup(groupPublicKey)
- } else {
- Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}")
- }
- }
+
+ val recipient = getRecipientForThread(threadID)
+ if (recipient == null) {
+ Log.w(TAG, "Got null recipient when deleting conversation - aborting.");
+ return
+ }
+
+ // There is nothing further we need to do if this is a 1-on-1 conversation, and it's not
+ // possible to delete communities in this manner so bail.
+ if (recipient.isContactRecipient || recipient.isCommunityRecipient) return
+
+ // If we get here then this is a closed group conversation (i.e., recipient.isClosedGroupRecipient)
+ val volatile = configFactory.convoVolatile ?: return
+ val groups = configFactory.userGroups ?: return
+ val groupID = recipient.address.toGroupString()
+ val closedGroup = getGroup(groupID)
+ val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize())
+ if (closedGroup != null) {
+ groupDB.delete(groupID)
+ volatile.eraseLegacyClosedGroup(groupPublicKey)
+ groups.eraseLegacyGroup(groupPublicKey)
+ } else {
+ Log.w("Loki-DBG", "Failed to find a closed group for ${groupPublicKey.take(4)}")
}
}
@@ -1329,14 +1411,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,
@@ -1351,6 +1436,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) {
@@ -1405,7 +1492,7 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
val blindedId = when {
recipient.isGroupRecipient -> null
recipient.isOpenGroupInboxRecipient -> {
- GroupUtil.getDecodedOpenGroupInbox(address)
+ GroupUtil.getDecodedOpenGroupInboxSessionId(address)
}
else -> {
if (SessionId(address).prefix == IdPrefix.BLINDED) {
@@ -1431,12 +1518,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,
@@ -1476,8 +1563,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 {
@@ -1524,16 +1618,12 @@ open class Storage(context: Context, helper: SQLCipherOpenHelper, private val co
if (mapping.sessionId != null) {
return mapping
}
- val threadDb = DatabaseComponent.get(context).threadDatabase()
- threadDb.readerFor(threadDb.conversationList).use { reader ->
- while (reader.next != null) {
- val recipient = reader.current.recipient
- val sessionId = recipient.address.serialize()
- if (!recipient.isGroupRecipient && SodiumUtilities.sessionId(sessionId, blindedId, serverPublicKey)) {
- val contactMapping = mapping.copy(sessionId = sessionId)
- db.addBlindedIdMapping(contactMapping)
- return contactMapping
- }
+ getAllContacts().forEach { contact ->
+ val sessionId = SessionId(contact.sessionID)
+ if (sessionId.prefix == IdPrefix.STANDARD && SodiumUtilities.sessionId(sessionId.hexString, blindedId, serverPublicKey)) {
+ val contactMapping = mapping.copy(sessionId = sessionId.hexString)
+ db.addBlindedIdMapping(contactMapping)
+ return contactMapping
}
}
db.getBlindedIdMappingsExceptFor(server).forEach {
@@ -1618,4 +1708,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 5044529981..f5c6da5fb9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java
@@ -18,7 +18,7 @@
package org.thoughtcrime.securesms.database;
import static org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX;
-import static org.session.libsession.utilities.GroupUtil.OPEN_GROUP_PREFIX;
+import static org.session.libsession.utilities.GroupUtil.COMMUNITY_PREFIX;
import static org.thoughtcrime.securesms.database.GroupDatabase.GROUP_ID;
import android.content.ContentValues;
@@ -26,14 +26,10 @@ import android.content.Context;
import android.database.Cursor;
import android.database.MergeCursor;
import android.net.Uri;
-
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-
import com.annimon.stream.Stream;
-
import net.zetetic.database.sqlcipher.SQLiteDatabase;
-
import org.jetbrains.annotations.NotNull;
import org.session.libsession.snode.SnodeAPI;
import org.session.libsession.utilities.Address;
@@ -50,8 +46,7 @@ import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.Pair;
import org.session.libsignal.utilities.guava.Optional;
import org.thoughtcrime.securesms.ApplicationContext;
-import org.thoughtcrime.securesms.contactshare.ContactUtil;
-import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
+import org.thoughtcrime.securesms.contacts.ContactUtil;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
@@ -62,7 +57,6 @@ import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.util.SessionMetaProtocol;
-
import java.io.Closeable;
import java.util.Collections;
import java.util.HashMap;
@@ -84,7 +78,7 @@ public class ThreadDatabase extends Database {
public static final String TABLE_NAME = "thread";
public static final String ID = "_id";
- public static final String DATE = "date";
+ public static final String THREAD_CREATION_DATE = "date";
public static final String MESSAGE_COUNT = "message_count";
public static final String ADDRESS = "recipient_ids";
public static final String SNIPPET = "snippet";
@@ -92,7 +86,7 @@ public class ThreadDatabase extends Database {
public static final String READ = "read";
public static final String UNREAD_COUNT = "unread_count";
public static final String UNREAD_MENTION_COUNT = "unread_mention_count";
- public static final String TYPE = "type";
+ public static final String DISTRIBUTION_TYPE = "type"; // See: DistributionTypes.kt
private static final String ERROR = "error";
public static final String SNIPPET_TYPE = "snippet_type";
public static final String SNIPPET_URI = "snippet_uri";
@@ -102,27 +96,27 @@ public class ThreadDatabase extends Database {
public static final String READ_RECEIPT_COUNT = "read_receipt_count";
public static final String EXPIRES_IN = "expires_in";
public static final String LAST_SEEN = "last_seen";
- public static final String HAS_SENT = "has_sent";
+ public static final String HAS_SENT = "has_sent";
public static final String IS_PINNED = "is_pinned";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" +
- ID + " INTEGER PRIMARY KEY, " + DATE + " INTEGER DEFAULT 0, " +
+ ID + " INTEGER PRIMARY KEY, " + THREAD_CREATION_DATE + " INTEGER DEFAULT 0, " +
MESSAGE_COUNT + " INTEGER DEFAULT 0, " + ADDRESS + " TEXT, " + SNIPPET + " TEXT, " +
SNIPPET_CHARSET + " INTEGER DEFAULT 0, " + READ + " INTEGER DEFAULT 1, " +
- TYPE + " INTEGER DEFAULT 0, " + ERROR + " INTEGER DEFAULT 0, " +
+ DISTRIBUTION_TYPE + " INTEGER DEFAULT 0, " + ERROR + " INTEGER DEFAULT 0, " +
SNIPPET_TYPE + " INTEGER DEFAULT 0, " + SNIPPET_URI + " TEXT DEFAULT NULL, " +
ARCHIVED + " INTEGER DEFAULT 0, " + STATUS + " INTEGER DEFAULT 0, " +
DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + EXPIRES_IN + " INTEGER DEFAULT 0, " +
LAST_SEEN + " INTEGER DEFAULT 0, " + HAS_SENT + " INTEGER DEFAULT 0, " +
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNREAD_COUNT + " INTEGER DEFAULT 0);";
- public static final String[] CREATE_INDEXS = {
+ public static final String[] CREATE_INDEXES = {
"CREATE INDEX IF NOT EXISTS thread_recipient_ids_index ON " + TABLE_NAME + " (" + ADDRESS + ");",
"CREATE INDEX IF NOT EXISTS archived_count_index ON " + TABLE_NAME + " (" + ARCHIVED + ", " + MESSAGE_COUNT + ");",
};
private static final String[] THREAD_PROJECTION = {
- ID, DATE, MESSAGE_COUNT, ADDRESS, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, UNREAD_MENTION_COUNT, TYPE, ERROR, SNIPPET_TYPE,
+ ID, THREAD_CREATION_DATE, MESSAGE_COUNT, ADDRESS, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, UNREAD_MENTION_COUNT, DISTRIBUTION_TYPE, ERROR, SNIPPET_TYPE,
SNIPPET_URI, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT, IS_PINNED
};
@@ -132,8 +126,8 @@ public class ThreadDatabase extends Database {
private static final List COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION = Stream.concat(Stream.concat(Stream.of(TYPED_THREAD_PROJECTION),
Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION)),
- Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION))
- .toList();
+ Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION))
+ .toList();
public static String getCreatePinnedCommand() {
return "ALTER TABLE "+ TABLE_NAME + " " +
@@ -159,11 +153,10 @@ public class ThreadDatabase extends Database {
ContentValues contentValues = new ContentValues(4);
long date = SnodeAPI.getNowWithOffset();
- contentValues.put(DATE, date - date % 1000);
+ contentValues.put(THREAD_CREATION_DATE, date - date % 1000);
contentValues.put(ADDRESS, address.serialize());
- if (group)
- contentValues.put(TYPE, distributionType);
+ if (group) contentValues.put(DISTRIBUTION_TYPE, distributionType);
contentValues.put(MESSAGE_COUNT, 0);
@@ -176,7 +169,7 @@ public class ThreadDatabase extends Database {
long expiresIn, int readReceiptCount)
{
ContentValues contentValues = new ContentValues(7);
- contentValues.put(DATE, date - date % 1000);
+ contentValues.put(THREAD_CREATION_DATE, date - date % 1000);
contentValues.put(MESSAGE_COUNT, count);
if (!body.isEmpty()) {
contentValues.put(SNIPPET, body);
@@ -188,9 +181,7 @@ public class ThreadDatabase extends Database {
contentValues.put(READ_RECEIPT_COUNT, readReceiptCount);
contentValues.put(EXPIRES_IN, expiresIn);
- if (unarchive) {
- contentValues.put(ARCHIVED, 0);
- }
+ if (unarchive) { contentValues.put(ARCHIVED, 0); }
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.update(TABLE_NAME, contentValues, ID + " = ?", new String[] {threadId + ""});
@@ -200,7 +191,7 @@ public class ThreadDatabase extends Database {
public void updateSnippet(long threadId, String snippet, @Nullable Uri attachment, long date, long type, boolean unarchive) {
ContentValues contentValues = new ContentValues(4);
- contentValues.put(DATE, date - date % 1000);
+ contentValues.put(THREAD_CREATION_DATE, date - date % 1000);
if (!snippet.isEmpty()) {
contentValues.put(SNIPPET, snippet);
}
@@ -231,9 +222,7 @@ public class ThreadDatabase extends Database {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
String where = "";
- for (long threadId : threadIds) {
- where += ID + " = '" + threadId + "' OR ";
- }
+ for (long threadId : threadIds) { where += ID + " = '" + threadId + "' OR "; }
where = where.substring(0, where.length() - 4);
@@ -359,7 +348,7 @@ public class ThreadDatabase extends Database {
public void setDistributionType(long threadId, int distributionType) {
ContentValues contentValues = new ContentValues(1);
- contentValues.put(TYPE, distributionType);
+ contentValues.put(DISTRIBUTION_TYPE, distributionType);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""});
@@ -368,7 +357,7 @@ public class ThreadDatabase extends Database {
public void setDate(long threadId, long date) {
ContentValues contentValues = new ContentValues(1);
- contentValues.put(DATE, date);
+ contentValues.put(THREAD_CREATION_DATE, date);
SQLiteDatabase db = databaseHelper.getWritableDatabase();
int updated = db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""});
if (updated > 0) notifyConversationListListeners();
@@ -376,11 +365,11 @@ public class ThreadDatabase extends Database {
public int getDistributionType(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
- Cursor cursor = db.query(TABLE_NAME, new String[]{TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
+ Cursor cursor = db.query(TABLE_NAME, new String[]{DISTRIBUTION_TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
try {
if (cursor != null && cursor.moveToNext()) {
- return cursor.getInt(cursor.getColumnIndexOrThrow(TYPE));
+ return cursor.getInt(cursor.getColumnIndexOrThrow(DISTRIBUTION_TYPE));
}
return DistributionTypes.DEFAULT;
@@ -428,7 +417,7 @@ public class ThreadDatabase extends Database {
}
Cursor cursor = cursors.size() > 1 ? new MergeCursor(cursors.toArray(new Cursor[cursors.size()])) : cursors.get(0);
- setNotifyConverationListListeners(cursor);
+ setNotifyConversationListListeners(cursor);
return cursor;
}
@@ -470,7 +459,7 @@ public class ThreadDatabase extends Database {
Cursor cursor = null;
try {
- String where = "SELECT " + DATE + " FROM " + TABLE_NAME +
+ String where = "SELECT " + THREAD_CREATION_DATE + " FROM " + TABLE_NAME +
" LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS +
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
@@ -478,7 +467,7 @@ public class ThreadDatabase extends Database {
" WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " +
RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " +
- GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL ORDER BY " + DATE + " DESC LIMIT 1";
+ GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL ORDER BY " + THREAD_CREATION_DATE + " DESC LIMIT 1";
cursor = db.rawQuery(where, null);
if (cursor != null && cursor.moveToFirst())
@@ -492,7 +481,7 @@ public class ThreadDatabase extends Database {
}
public Cursor getConversationList() {
- String where = "(" + MESSAGE_COUNT + " != 0 OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " +
+ String where = "(" + MESSAGE_COUNT + " != 0 OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " +
"AND " + ARCHIVED + " = 0 ";
return getConversationList(where);
}
@@ -503,7 +492,7 @@ public class ThreadDatabase extends Database {
}
public Cursor getApprovedConversationList() {
- String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " +
+ String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " +
"AND " + ARCHIVED + " = 0 ";
return getConversationList(where);
}
@@ -516,18 +505,12 @@ public class ThreadDatabase extends Database {
return getConversationList(where);
}
- public Cursor getArchivedConversationList() {
- String where = "(" + MESSAGE_COUNT + " != 0 OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " +
- "AND " + ARCHIVED + " = 1 ";
- return getConversationList(where);
- }
-
private Cursor getConversationList(String where) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String query = createQuery(where, 0);
Cursor cursor = db.rawQuery(query, null);
- setNotifyConverationListListeners(cursor);
+ setNotifyConversationListListeners(cursor);
return cursor;
}
@@ -548,7 +531,7 @@ public class ThreadDatabase extends Database {
// edge case where we set the last seen time for a conversation before it loads messages (joining community for example)
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
Recipient forThreadId = getRecipientForThreadId(threadId);
- if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && forThreadId != null && forThreadId.isOpenGroupRecipient()) return false;
+ if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && forThreadId != null && forThreadId.isCommunityRecipient()) return false;
SQLiteDatabase db = databaseHelper.getWritableDatabase();
@@ -602,7 +585,7 @@ public class ThreadDatabase extends Database {
public Long getLastUpdated(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
- Cursor cursor = db.query(TABLE_NAME, new String[]{DATE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
+ Cursor cursor = db.query(TABLE_NAME, new String[]{THREAD_CREATION_DATE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null);
try {
if (cursor != null && cursor.moveToFirst()) {
@@ -743,7 +726,7 @@ public class ThreadDatabase extends Database {
MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
long count = mmsSmsDatabase.getConversationCount(threadId);
- boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && deleteThreadOnEmpty(threadId);
+ boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && possibleToDeleteThreadOnEmpty(threadId);
if (count == 0 && shouldDeleteEmptyThread) {
deleteThread(threadId);
@@ -751,10 +734,7 @@ public class ThreadDatabase extends Database {
return true;
}
- MmsSmsDatabase.Reader reader = null;
-
- try {
- reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId));
+ try (MmsSmsDatabase.Reader reader = mmsSmsDatabase.readerFor(mmsSmsDatabase.getConversationSnippet(threadId))) {
MessageRecord record = null;
if (reader != null) {
record = reader.getNext();
@@ -772,11 +752,10 @@ public class ThreadDatabase extends Database {
deleteThread(threadId);
return true;
}
+ // todo: add empty snippet that clears existing data
return false;
}
} finally {
- if (reader != null)
- reader.close();
notifyConversationListListeners();
notifyConversationListeners(threadId);
}
@@ -816,20 +795,14 @@ 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);
}
- private boolean deleteThreadOnEmpty(long threadId) {
+ private boolean possibleToDeleteThreadOnEmpty(long threadId) {
Recipient threadRecipient = getRecipientForThreadId(threadId);
- return threadRecipient != null && !threadRecipient.isOpenGroupRecipient();
+ return threadRecipient != null && !threadRecipient.isCommunityRecipient();
}
private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) {
@@ -872,7 +845,7 @@ public class ThreadDatabase extends Database {
" LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID +
" WHERE " + where +
- " ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + DATE + " DESC";
+ " ORDER BY " + TABLE_NAME + "." + IS_PINNED + " DESC, " + TABLE_NAME + "." + THREAD_CREATION_DATE + " DESC";
if (limit > 0) {
query += " LIMIT " + limit;
@@ -908,6 +881,10 @@ public class ThreadDatabase extends Database {
this.cursor = cursor;
}
+ public int getCount() {
+ return cursor == null ? 0 : cursor.getCount();
+ }
+
public ThreadRecord getNext() {
if (cursor == null || !cursor.moveToNext())
return null;
@@ -917,7 +894,7 @@ public class ThreadDatabase extends Database {
public ThreadRecord getCurrent() {
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.ID));
- int distributionType = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.TYPE));
+ int distributionType = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.DISTRIBUTION_TYPE));
Address address = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.ADDRESS)));
Optional settings;
@@ -933,7 +910,7 @@ public class ThreadDatabase extends Database {
Recipient recipient = Recipient.from(context, address, settings, groupRecord, true);
String body = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET));
- long date = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.DATE));
+ long date = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.THREAD_CREATION_DATE));
long count = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT));
int unreadCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT));
int unreadMentionCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_MENTION_COUNT));
@@ -951,7 +928,17 @@ public class ThreadDatabase extends Database {
readReceiptCount = 0;
}
- return new ThreadRecord(body, snippetUri, recipient, date, count,
+ MessageRecord lastMessage = null;
+
+ if (count > 0) {
+ MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase();
+ long messageTimestamp = mmsSmsDatabase.getLastMessageTimestamp(threadId);
+ if (messageTimestamp > 0) {
+ lastMessage = mmsSmsDatabase.getMessageForTimestamp(messageTimestamp);
+ }
+ }
+
+ return new ThreadRecord(body, snippetUri, lastMessage, recipient, date, count,
unreadCount, unreadMentionCount, threadId, deliveryReceiptCount, status, type,
distributionType, archived, expiresIn, lastSeen, readReceiptCount, pinned);
}
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 89bda09948..cd1988e83f 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;
@@ -88,9 +89,13 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int lokiV40 = 61;
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 = lokiV42;
+ 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";
@@ -310,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());
@@ -323,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);
@@ -344,11 +352,12 @@ 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);
executeStatements(db, AttachmentDatabase.CREATE_INDEXS);
- executeStatements(db, ThreadDatabase.CREATE_INDEXS);
+ executeStatements(db, ThreadDatabase.CREATE_INDEXES);
executeStatements(db, DraftDatabase.CREATE_INDEXS);
executeStatements(db, GroupDatabase.CREATE_INDEXS);
executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES);
@@ -356,6 +365,8 @@ 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
@@ -598,6 +609,30 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(RecipientDatabase.getAddWrapperHash());
}
+ if (oldVersion < lokiV43) {
+ 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/DisplayRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java
index 39fba182aa..639ea0db09 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java
@@ -22,7 +22,10 @@ import android.text.SpannableString;
import androidx.annotation.NonNull;
import org.session.libsession.utilities.recipients.Recipient;
+import org.session.libsignal.utilities.Log;
+import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
+import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
/**
@@ -48,6 +51,9 @@ public abstract class DisplayRecord {
long dateReceived, long threadId, int deliveryStatus, int deliveryReceiptCount,
long type, int readReceiptCount)
{
+ // TODO: This gets hit very, very often and it likely shouldn't - place a Log.d statement in it to see.
+ //Log.d("[ACL]", "Creating a display record with delivery status of: " + deliveryStatus);
+
this.threadId = threadId;
this.recipient = recipient;
this.dateSent = dateSent;
@@ -72,13 +78,11 @@ public abstract class DisplayRecord {
public int getReadReceiptCount() { return readReceiptCount; }
public boolean isDelivered() {
- return (deliveryStatus >= SmsDatabase.Status.STATUS_COMPLETE
- && deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0;
+ return (deliveryStatus >= SmsDatabase.Status.STATUS_COMPLETE &&
+ deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0;
}
- public boolean isSent() {
- return !isFailed() && !isPending();
- }
+ public boolean isSent() { return MmsSmsColumns.Types.isSentType(type); }
public boolean isSyncing() {
return MmsSmsColumns.Types.isSyncingType(type);
@@ -99,9 +103,10 @@ public abstract class DisplayRecord {
}
public boolean isPending() {
- return MmsSmsColumns.Types.isPendingMessageType(type)
- && !MmsSmsColumns.Types.isIdentityVerified(type)
- && !MmsSmsColumns.Types.isIdentityDefault(type);
+ boolean isPending = MmsSmsColumns.Types.isPendingMessageType(type) &&
+ !MmsSmsColumns.Types.isIdentityVerified(type) &&
+ !MmsSmsColumns.Types.isIdentityDefault(type);
+ return isPending;
}
public boolean isRead() { return readReceiptCount > 0; }
@@ -109,6 +114,11 @@ public abstract class DisplayRecord {
public boolean isOutgoing() {
return MmsSmsColumns.Types.isOutgoingMessageType(type);
}
+
+ public boolean isIncoming() {
+ return !MmsSmsColumns.Types.isOutgoingMessageType(type);
+ }
+
public boolean isGroupUpdateMessage() {
return SmsDatabase.Types.isGroupUpdateMessage(type);
}
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..a61b78b4b6 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
@@ -31,6 +31,7 @@ import org.session.libsession.messaging.utilities.UpdateMessageData;
import org.session.libsession.utilities.IdentityKeyMismatch;
import org.session.libsession.utilities.NetworkFailure;
import org.session.libsession.utilities.recipients.Recipient;
+import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import java.util.List;
import java.util.Objects;
@@ -54,6 +55,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 +121,8 @@ 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()));
+ boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupRecipient();
+ return new SpannableString(UpdateMessageBuilder.INSTANCE.buildExpirationTimerMessage(context, seconds, isGroup, 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/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java
index f3e72a8747..0c023a8f29 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java
@@ -43,6 +43,7 @@ import network.loki.messenger.R;
public class ThreadRecord extends DisplayRecord {
private @Nullable final Uri snippetUri;
+ public @Nullable final MessageRecord lastMessage;
private final long count;
private final int unreadCount;
private final int unreadMentionCount;
@@ -54,13 +55,14 @@ public class ThreadRecord extends DisplayRecord {
private final int initialRecipientHash;
public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri,
- @NonNull Recipient recipient, long date, long count, int unreadCount,
+ @Nullable MessageRecord lastMessage, @NonNull Recipient recipient, long date, long count, int unreadCount,
int unreadMentionCount, long threadId, int deliveryReceiptCount, int status,
long snippetType, int distributionType, boolean archived, long expiresIn,
long lastSeen, int readReceiptCount, boolean pinned)
{
super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount);
this.snippetUri = snippetUri;
+ this.lastMessage = lastMessage;
this.count = count;
this.unreadCount = unreadCount;
this.unreadMentionCount = unreadMentionCount;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt
index 936e4f287f..a9a72e7665 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt
@@ -19,11 +19,11 @@ abstract class AppModule {
@Binds
abstract fun bindConversationRepository(repository: DefaultConversationRepository): ConversationRepository
-
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AppComponent {
fun getPrefs(): TextSecurePreferences
+
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt
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/ContentModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ContentModule.kt
new file mode 100644
index 0000000000..89098a0f16
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ContentModule.kt
@@ -0,0 +1,17 @@
+package org.thoughtcrime.securesms.dependencies
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ContentModule {
+
+ @Provides
+ fun providesContentResolver(@ApplicationContext context: Context) =context.contentResolver
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseComponent.kt
index 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/glide/PlaceholderAvatarLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt
index 69c9b8c4f5..b163b5ed90 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/glide/PlaceholderAvatarLoader.kt
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.glide
+import android.content.Context
import android.graphics.drawable.BitmapDrawable
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.ModelLoader
@@ -8,7 +9,7 @@ import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import org.session.libsession.avatars.PlaceholderAvatarPhoto
-class PlaceholderAvatarLoader(): ModelLoader {
+class PlaceholderAvatarLoader(private val appContext: Context): ModelLoader {
override fun buildLoadData(
model: PlaceholderAvatarPhoto,
@@ -16,14 +17,14 @@ class PlaceholderAvatarLoader(): ModelLoader {
- return LoadData(model, PlaceholderAvatarFetcher(model.context, model))
+ return LoadData(model, PlaceholderAvatarFetcher(appContext, model))
}
override fun handles(model: PlaceholderAvatarPhoto): Boolean = true
- class Factory() : ModelLoaderFactory {
+ class Factory(private val appContext: Context) : ModelLoaderFactory {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader {
- return PlaceholderAvatarLoader()
+ return PlaceholderAvatarLoader(appContext)
}
override fun teardown() {}
}
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 8b362d70d1..adeeeb91fa 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt
@@ -3,12 +3,11 @@ package org.thoughtcrime.securesms.groups
import android.content.Context
import network.loki.messenger.libsession_util.ConfigBase
import org.session.libsession.messaging.MessagingModuleConfiguration
-import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
+import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil
-import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.dependencies.ConfigFactory
@@ -24,7 +23,7 @@ object ClosedGroupManager {
storage.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey)
storage.removeMember(groupID, Address.fromSerialized(userPublicKey))
// Notify the PN server
- PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey)
+ PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey)
// Stop polling
ClosedGroupPollerV2.shared.stopPolling(groupPublicKey)
storage.cancelPendingMessageSendJobs(threadId)
@@ -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/CreateGroupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt
index ead979b773..75c7681b1d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt
@@ -21,6 +21,7 @@ import nl.komponents.kovenant.ui.successUi
import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.groupSizeLimit
import org.session.libsession.utilities.Address
+import org.session.libsession.utilities.Device
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.contacts.SelectContactsAdapter
@@ -31,10 +32,14 @@ import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.fadeIn
import org.thoughtcrime.securesms.util.fadeOut
+import javax.inject.Inject
@AndroidEntryPoint
class CreateGroupFragment : Fragment() {
+ @Inject
+ lateinit var device: Device
+
private lateinit var binding: FragmentCreateGroupBinding
private val viewModel: CreateGroupViewModel by viewModels()
@@ -73,7 +78,7 @@ class CreateGroupFragment : Fragment() {
if (name.isEmpty()) {
return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_missing_error, Toast.LENGTH_LONG).show()
}
- if (name.length >= 30) {
+ if (name.length > resources.getInteger(R.integer.max_group_and_community_name_length_chars)) {
return@setOnClickListener Toast.makeText(context, R.string.activity_create_closed_group_group_name_too_long_error, Toast.LENGTH_LONG).show()
}
val selectedMembers = adapter.selectedMembers
@@ -86,7 +91,7 @@ class CreateGroupFragment : Fragment() {
val userPublicKey = TextSecurePreferences.getLocalNumber(requireContext())!!
isLoading = true
binding.loaderContainer.fadeIn()
- MessageSender.createClosedGroup(name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID ->
+ MessageSender.createClosedGroup(device, name.toString(), selectedMembers + setOf( userPublicKey )).successUi { groupID ->
binding.loaderContainer.fadeOut()
isLoading = false
val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getOrCreateThreadIdFor(Recipient.from(requireContext(), Address.fromSerialized(groupID), false))
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/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt
index 702bf33929..82b9f16dcd 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt
@@ -11,7 +11,6 @@ import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
-import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.getConversationUnread
import javax.inject.Inject
@@ -75,7 +74,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
}
binding.copyConversationId.visibility = if (!recipient.isGroupRecipient && !recipient.isLocalNumber) View.VISIBLE else View.GONE
binding.copyConversationId.setOnClickListener(this)
- binding.copyCommunityUrl.visibility = if (recipient.isOpenGroupRecipient) View.VISIBLE else View.GONE
+ binding.copyCommunityUrl.visibility = if (recipient.isCommunityRecipient) View.VISIBLE else View.GONE
binding.copyCommunityUrl.setOnClickListener(this)
binding.unMuteNotificationsTextView.isVisible = recipient.isMuted && !recipient.isLocalNumber
binding.muteNotificationsTextView.isVisible = !recipient.isMuted && !recipient.isLocalNumber
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..c9896a5b8e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationView.kt
@@ -4,6 +4,8 @@ import android.content.Context
import android.content.res.Resources
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
+import android.text.SpannableString
+import android.text.TextUtils
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
@@ -89,10 +91,10 @@ class ConversationView : LinearLayout {
|| (configFactory.convoVolatile?.getConversationUnread(thread) == true)
binding.unreadMentionTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize)
binding.unreadMentionIndicator.isVisible = (thread.unreadMentionCount != 0 && thread.recipient.address.isGroup)
- val senderDisplayName = getUserDisplayName(thread.recipient)
+ val senderDisplayName = getTitle(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) {
@@ -101,9 +103,7 @@ class ConversationView : LinearLayout {
R.drawable.ic_notifications_mentions
}
binding.muteIndicatorImageView.setImageResource(drawableRes)
- val rawSnippet = thread.getDisplayBody(context)
- val snippet = highlightMentions(rawSnippet, thread.threadId, context)
- binding.snippetTextView.text = snippet
+ binding.snippetTextView.text = highlightMentions(thread.getSnippet(), thread.threadId, context)
binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE
if (isTyping) {
@@ -131,12 +131,21 @@ class ConversationView : LinearLayout {
binding.profilePictureView.recycle()
}
- private fun getUserDisplayName(recipient: Recipient): String? {
- return if (recipient.isLocalNumber) {
- context.getString(R.string.note_to_self)
- } else {
- recipient.toShortString() // Internally uses the Contact API
- }
+ private fun getTitle(recipient: Recipient): String? = when {
+ recipient.isLocalNumber -> context.getString(R.string.note_to_self)
+ else -> recipient.toShortString() // Internally uses the Contact API
+ }
+
+ private fun ThreadRecord.getSnippet(): CharSequence =
+ concatSnippet(getSnippetPrefix(), getDisplayBody(context))
+
+ private fun concatSnippet(prefix: CharSequence?, body: CharSequence): CharSequence =
+ prefix?.let { TextUtils.concat(it, ": ", body) } ?: body
+
+ private fun ThreadRecord.getSnippetPrefix(): CharSequence? = when {
+ recipient.isLocalNumber || lastMessage?.isControlMessage == true -> null
+ lastMessage?.isOutgoing == true -> resources.getString(R.string.MessageRecord_you)
+ else -> lastMessage?.individualRecipient?.toShortString()
}
// endregion
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt
index 72bd098f4f..c063f30538 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt
@@ -2,12 +2,10 @@ package org.thoughtcrime.securesms.home
import android.Manifest
import android.app.NotificationManager
-import android.content.BroadcastReceiver
import android.content.ClipData
import android.content.ClipboardManager
-import android.content.Context
import android.content.Intent
-import android.content.IntentFilter
+import android.os.Build
import android.os.Bundle
import android.text.SpannableString
import android.widget.Toast
@@ -17,19 +15,18 @@ import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
-import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityHomeBinding
-import network.loki.messenger.databinding.ViewMessageRequestBannerBinding
import network.loki.messenger.libsession_util.ConfigBase
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
@@ -67,21 +64,19 @@ import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
+import org.thoughtcrime.securesms.notifications.PushRegistry
import org.thoughtcrime.securesms.onboarding.SeedActivity
import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.SettingsActivity
-import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.showMuteDialog
+import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
-import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.IP2Country
import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show
-import org.thoughtcrime.securesms.util.themeState
import java.io.IOException
-import java.util.Locale
import javax.inject.Inject
@AndroidEntryPoint
@@ -97,7 +92,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private lateinit var binding: ActivityHomeBinding
private lateinit var glide: GlideRequests
- private var broadcastReceiver: BroadcastReceiver? = null
@Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var mmsSmsDatabase: MmsSmsDatabase
@@ -106,6 +100,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
@Inject lateinit var groupDatabase: GroupDatabase
@Inject lateinit var textSecurePreferences: TextSecurePreferences
@Inject lateinit var configFactory: ConfigFactory
+ @Inject lateinit var pushRegistry: PushRegistry
private val globalSearchViewModel by viewModels()
private val homeViewModel by viewModels()
@@ -114,7 +109,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
get() = textSecurePreferences.getLocalNumber()!!
private val homeAdapter: HomeAdapter by lazy {
- HomeAdapter(context = this, configFactory = configFactory, listener = this)
+ HomeAdapter(context = this, configFactory = configFactory, listener = this, ::showMessageRequests, ::hideMessageRequests)
}
private val globalSearchAdapter = GlobalSearchAdapter { model ->
@@ -186,7 +181,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
binding.seedReminderView.isVisible = false
}
}
- setupMessageRequestsBanner()
// Set up recycler view
binding.globalSearchInputLayout.listener = this
homeAdapter.setHasStableIds(true)
@@ -202,18 +196,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// Set up empty state view
binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() }
IP2Country.configureIfNeeded(this@HomeActivity)
- startObservingUpdates()
// Set up new conversation button
binding.newConversationButton.setOnClickListener { showNewConversation() }
// Observe blocked contacts changed events
- val broadcastReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- binding.recyclerView.adapter!!.notifyDataSetChanged()
- }
- }
- this.broadcastReceiver = broadcastReceiver
- LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged"))
// subscribe to outdated config updates, this should be removed after long enough time for device migration
lifecycleScope.launch {
@@ -224,14 +210,33 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
}
+ // Subscribe to threads and update the UI
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ homeViewModel.data
+ .filterNotNull() // We don't actually want the null value here as it indicates a loading state (maybe we need a loading state?)
+ .collectLatest { data ->
+ val manager = binding.recyclerView.layoutManager as LinearLayoutManager
+ val firstPos = manager.findFirstCompletelyVisibleItemPosition()
+ val offsetTop = if(firstPos >= 0) {
+ manager.findViewByPosition(firstPos)?.let { view ->
+ manager.getDecoratedTop(view) - manager.getTopDecorationHeight(view)
+ } ?: 0
+ } else 0
+ homeAdapter.data = data
+ if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) }
+ updateEmptyState()
+ }
+ }
+ }
+
lifecycleScope.launchWhenStarted {
launch(Dispatchers.IO) {
// Double check that the long poller is up
(applicationContext as ApplicationContext).startPollingIfNeeded()
// update things based on TextSecurePrefs (profile info etc)
// Set up remaining components if needed
- val application = ApplicationContext.getInstance(this@HomeActivity)
- application.registerForFCMIfNeeded(false)
+ pushRegistry.refresh(false)
if (textSecurePreferences.getLocalNumber() != null) {
OpenGroupManager.startPolling()
JobQueue.shared.resumePendingJobs()
@@ -291,19 +296,24 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
val newData = contactResults + messageResults
-
globalSearchAdapter.setNewData(result.query, newData)
}
}
}
EventBus.getDefault().register(this@HomeActivity)
if (intent.hasExtra(FROM_ONBOARDING)
- && intent.getBooleanExtra(FROM_ONBOARDING, false)
- && !(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()
- ) {
- Permissions.with(this)
- .request(Manifest.permission.POST_NOTIFICATIONS)
- .execute()
+ && intent.getBooleanExtra(FROM_ONBOARDING, false)) {
+ if (Build.VERSION.SDK_INT >= 33 &&
+ (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled().not()) {
+ Permissions.with(this)
+ .request(Manifest.permission.POST_NOTIFICATIONS)
+ .execute()
+ }
+ configFactory.user?.let { user ->
+ if (!user.isBlockCommunityMessageRequestsSet()) {
+ user.setCommunityMessageRequests(false)
+ }
+ }
}
}
@@ -325,34 +335,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
binding.newConversationButton.isVisible = !isShown
}
- private fun setupMessageRequestsBanner() {
- val messageRequestCount = threadDb.unapprovedConversationCount
- // Set up message requests
- if (messageRequestCount > 0 && !textSecurePreferences.hasHiddenMessageRequests()) {
- with(ViewMessageRequestBannerBinding.inflate(layoutInflater)) {
- unreadCountTextView.text = messageRequestCount.toString()
- timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(
- this@HomeActivity,
- Locale.getDefault(),
- threadDb.latestUnapprovedConversationTimestamp
- )
- root.setOnClickListener { showMessageRequests() }
- root.setOnLongClickListener { hideMessageRequests(); true }
- root.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
- val hadHeader = homeAdapter.hasHeaderView()
- homeAdapter.header = root
- if (hadHeader) homeAdapter.notifyItemChanged(0)
- else homeAdapter.notifyItemInserted(0)
- }
- } else {
- val hadHeader = homeAdapter.hasHeaderView()
- homeAdapter.header = null
- if (hadHeader) {
- homeAdapter.notifyItemRemoved(0)
- }
- }
- }
-
private fun updateLegacyConfigView() {
binding.configOutdatedView.isVisible = ConfigBase.isNewConfigEnabled(textSecurePreferences.hasForcedNewConfig(), SnodeAPI.nowWithOffset)
&& textSecurePreferences.getHasLegacyConfig()
@@ -378,52 +360,20 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity)
}
}
-
- // If the theme hasn't changed then start observing updates again (if it does change then we
- // will recreate the activity resulting in it responding to changes multiple times)
- if (currentThemeState == textSecurePreferences.themeState() && !homeViewModel.getObservable(this).hasActiveObservers()) {
- startObservingUpdates()
- }
}
override fun onPause() {
super.onPause()
ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(false)
-
- homeViewModel.getObservable(this).removeObservers(this)
}
override fun onDestroy() {
- val broadcastReceiver = this.broadcastReceiver
- if (broadcastReceiver != null) {
- LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver)
- }
super.onDestroy()
EventBus.getDefault().unregister(this)
}
// endregion
// region Updating
- private fun startObservingUpdates() {
- homeViewModel.getObservable(this).observe(this) { newData ->
- val manager = binding.recyclerView.layoutManager as LinearLayoutManager
- val firstPos = manager.findFirstCompletelyVisibleItemPosition()
- val offsetTop = if(firstPos >= 0) {
- manager.findViewByPosition(firstPos)?.let { view ->
- manager.getDecoratedTop(view) - manager.getTopDecorationHeight(view)
- } ?: 0
- } else 0
- homeAdapter.data = newData
- if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) }
- setupMessageRequestsBanner()
- updateEmptyState()
- }
-
- ApplicationContext.getInstance(this@HomeActivity).typingStatusRepository.typingThreads.observe(this) { threadIds ->
- homeAdapter.typingThreadIDs = (threadIds ?: setOf())
- }
- }
-
private fun updateEmptyState() {
val threadCount = (binding.recyclerView.adapter)!!.itemCount
binding.emptyStateContainer.isVisible = threadCount == 0 && binding.recyclerView.isVisible
@@ -434,7 +384,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
if (event.recipient.isLocalNumber) {
updateProfileButton()
} else {
- homeViewModel.tryUpdateChannel()
+ homeViewModel.tryReload()
}
}
@@ -447,6 +397,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// endregion
// region Interaction
+ @Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (binding.globalSearchRecycler.isVisible) {
binding.globalSearchInputLayout.clearSearch(true)
@@ -487,7 +438,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
manager.setPrimaryClip(clip)
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
- else if (thread.recipient.isOpenGroupRecipient) {
+ else if (thread.recipient.isCommunityRecipient) {
val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient) ?: return@onCopyConversationId Unit
val openGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return@onCopyConversationId Unit
@@ -604,7 +555,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private fun setConversationPinned(threadId: Long, pinned: Boolean) {
lifecycleScope.launch(Dispatchers.IO) {
storage.setPinned(threadId, pinned)
- homeViewModel.tryUpdateChannel()
+ homeViewModel.tryReload()
}
}
@@ -679,8 +630,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
text("Hide message requests?")
button(R.string.yes) {
textSecurePreferences.setHasHiddenMessageRequests()
- setupMessageRequestsBanner()
- homeViewModel.tryUpdateChannel()
+ homeViewModel.tryReload()
}
button(R.string.no)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt
index eaf242aae3..571adb7358 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt
@@ -9,14 +9,18 @@ import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import network.loki.messenger.R
-import org.thoughtcrime.securesms.database.model.ThreadRecord
+import network.loki.messenger.databinding.ViewMessageRequestBannerBinding
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.mms.GlideRequests
+import org.thoughtcrime.securesms.util.DateUtils
+import java.util.Locale
class HomeAdapter(
private val context: Context,
private val configFactory: ConfigFactory,
- private val listener: ConversationClickListener
+ private val listener: ConversationClickListener,
+ private val showMessageRequests: () -> Unit,
+ private val hideMessageRequests: () -> Unit,
) : RecyclerView.Adapter(), ListUpdateCallback {
companion object {
@@ -24,23 +28,32 @@ class HomeAdapter(
private const val ITEM = 1
}
- var header: View? = null
+ var messageRequests: HomeViewModel.MessageRequests? = null
+ set(value) {
+ if (field == value) return
+ val hadHeader = hasHeaderView()
+ field = value
+ if (value != null) {
+ if (hadHeader) notifyItemChanged(0) else notifyItemInserted(0)
+ } else if (hadHeader) notifyItemRemoved(0)
+ }
- private var _data: List = emptyList()
- var data: List
- get() = _data.toList()
+ var data: HomeViewModel.Data = HomeViewModel.Data()
set(newData) {
- val previousData = _data.toList()
- val diff = HomeDiffUtil(previousData, newData, context, configFactory)
+ if (field === newData) return
+
+ messageRequests = newData.messageRequests
+
+ val diff = HomeDiffUtil(field, newData, context, configFactory)
val diffResult = DiffUtil.calculateDiff(diff)
- _data = newData
+ field = newData
diffResult.dispatchUpdatesTo(this as ListUpdateCallback)
}
- fun hasHeaderView(): Boolean = header != null
+ fun hasHeaderView(): Boolean = messageRequests != null
private val headerCount: Int
- get() = if (header == null) 0 else 1
+ get() = if (messageRequests == null) 0 else 1
override fun onInserted(position: Int, count: Int) {
notifyItemRangeInserted(position + headerCount, count)
@@ -61,23 +74,19 @@ class HomeAdapter(
override fun getItemId(position: Int): Long {
if (hasHeaderView() && position == 0) return NO_ID
val offsetPosition = if (hasHeaderView()) position-1 else position
- return _data[offsetPosition].threadId
+ return data.threads[offsetPosition].threadId
}
lateinit var glide: GlideRequests
- var typingThreadIDs = setOf()
- set(value) {
- if (field == value) { return }
-
- field = value
- // TODO: replace this with a diffed update or a partial change set with payloads
- notifyDataSetChanged()
- }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
when (viewType) {
HEADER -> {
- HeaderFooterViewHolder(header!!)
+ ViewMessageRequestBannerBinding.inflate(LayoutInflater.from(parent.context)).apply {
+ root.setOnClickListener { showMessageRequests() }
+ root.setOnLongClickListener { hideMessageRequests(); true }
+ root.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
+ }.let(::HeaderFooterViewHolder)
}
ITEM -> {
val conversationView = LayoutInflater.from(parent.context).inflate(R.layout.view_conversation, parent, false) as ConversationView
@@ -93,19 +102,27 @@ class HomeAdapter(
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
- if (holder is ConversationViewHolder) {
- val offset = if (hasHeaderView()) position - 1 else position
- val thread = data[offset]
- val isTyping = typingThreadIDs.contains(thread.threadId)
- holder.view.bind(thread, isTyping, glide)
+ when (holder) {
+ is HeaderFooterViewHolder -> {
+ holder.binding.run {
+ messageRequests?.let {
+ unreadCountTextView.text = it.count
+ timestampTextView.text = it.timestamp
+ }
+ }
+ }
+ is ConversationViewHolder -> {
+ val offset = if (hasHeaderView()) position - 1 else position
+ val thread = data.threads[offset]
+ val isTyping = data.typingThreadIDs.contains(thread.threadId)
+ holder.view.bind(thread, isTyping, glide)
+ }
}
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if (holder is ConversationViewHolder) {
holder.view.recycle()
- } else {
- super.onViewRecycled(holder)
}
}
@@ -113,10 +130,9 @@ class HomeAdapter(
if (hasHeaderView() && position == 0) HEADER
else ITEM
- override fun getItemCount(): Int = data.size + if (hasHeaderView()) 1 else 0
+ override fun getItemCount(): Int = data.threads.size + if (hasHeaderView()) 1 else 0
class ConversationViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view)
- class HeaderFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
-
-}
\ No newline at end of file
+ class HeaderFooterViewHolder(val binding: ViewMessageRequestBannerBinding) : RecyclerView.ViewHolder(binding.root)
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt
index 0fe93d41de..89f02ee21a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDiffUtil.kt
@@ -2,27 +2,26 @@ package org.thoughtcrime.securesms.home
import android.content.Context
import androidx.recyclerview.widget.DiffUtil
-import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.getConversationUnread
class HomeDiffUtil(
- private val old: List,
- private val new: List,
- private val context: Context,
- private val configFactory: ConfigFactory
+ private val old: HomeViewModel.Data,
+ private val new: HomeViewModel.Data,
+ private val context: Context,
+ private val configFactory: ConfigFactory
): DiffUtil.Callback() {
- override fun getOldListSize(): Int = old.size
+ override fun getOldListSize(): Int = old.threads.size
- override fun getNewListSize(): Int = new.size
+ override fun getNewListSize(): Int = new.threads.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
- old[oldItemPosition].threadId == new[newItemPosition].threadId
+ old.threads[oldItemPosition].threadId == new.threads[newItemPosition].threadId
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
- val oldItem = old[oldItemPosition]
- val newItem = new[newItemPosition]
+ val oldItem = old.threads[oldItemPosition]
+ val newItem = new.threads[newItemPosition]
// return early to save getDisplayBody or expensive calls
var isSameItem = true
@@ -47,7 +46,8 @@ class HomeDiffUtil(
oldItem.isSent == newItem.isSent &&
oldItem.isPending == newItem.isPending &&
oldItem.lastSeen == newItem.lastSeen &&
- configFactory.convoVolatile?.getConversationUnread(newItem) != true
+ configFactory.convoVolatile?.getConversationUnread(newItem) != true &&
+ old.typingThreadIDs.contains(oldItem.threadId) == new.typingThreadIDs.contains(newItem.threadId)
)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt
index cb3322e039..fa18a995b6 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt
@@ -1,71 +1,131 @@
package org.thoughtcrime.securesms.home
+import android.content.ContentResolver
import android.content.Context
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
+import android.util.Log
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
-import app.cash.copper.flow.observeQuery
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.*
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import org.session.libsession.utilities.TextSecurePreferences
+import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord
-import java.lang.ref.WeakReference
+import org.thoughtcrime.securesms.util.DateUtils
+import org.thoughtcrime.securesms.util.observeChanges
+import java.util.Locale
import javax.inject.Inject
+import dagger.hilt.android.qualifiers.ApplicationContext as ApplicationContextQualifier
@HiltViewModel
-class HomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): ViewModel() {
+class HomeViewModel @Inject constructor(
+ private val threadDb: ThreadDatabase,
+ private val contentResolver: ContentResolver,
+ private val prefs: TextSecurePreferences,
+ @ApplicationContextQualifier private val context: Context,
+) : ViewModel() {
+ // SharedFlow that emits whenever the user asks us to reload the conversation
+ private val manualReloadTrigger = MutableSharedFlow(
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
- private val executor = viewModelScope + SupervisorJob()
- private var lastContext: WeakReference? = null
- private var updateJobs: MutableList = mutableListOf()
+ /**
+ * A [StateFlow] that emits the list of threads and the typing status of each thread.
+ *
+ * This flow will emit whenever the user asks us to reload the conversation list or
+ * whenever the conversation list changes.
+ */
+ val data: StateFlow = combine(
+ observeConversationList(),
+ observeTypingStatus(),
+ messageRequests(),
+ ::Data
+ )
+ .stateIn(viewModelScope, SharingStarted.Eagerly, null)
- private val _conversations = MutableLiveData>()
- val conversations: LiveData> = _conversations
+ private fun hasHiddenMessageRequests() = TextSecurePreferences.events
+ .filter { it == TextSecurePreferences.HAS_HIDDEN_MESSAGE_REQUESTS }
+ .flowOn(Dispatchers.IO)
+ .map { prefs.hasHiddenMessageRequests() }
+ .onStart { emit(prefs.hasHiddenMessageRequests()) }
- private val listUpdateChannel = Channel(capacity = Channel.CONFLATED)
+ private fun observeTypingStatus(): Flow> =
+ ApplicationContext.getInstance(context).typingStatusRepository
+ .typingThreads
+ .asFlow()
+ .onStart { emit(emptySet()) }
+ .distinctUntilChanged()
- fun tryUpdateChannel() = listUpdateChannel.trySend(Unit)
+ private fun messageRequests() = combine(
+ unapprovedConversationCount(),
+ hasHiddenMessageRequests(),
+ latestUnapprovedConversationTimestamp(),
+ ::createMessageRequests
+ )
- fun getObservable(context: Context): LiveData> {
- // If the context has changed (eg. the activity gets recreated) then
- // we need to cancel the old executors and recreate them to prevent
- // the app from triggering extra updates when data changes
- if (context != lastContext?.get()) {
- lastContext = WeakReference(context)
- updateJobs.forEach { it.cancel() }
- updateJobs.clear()
+ private fun unapprovedConversationCount() = reloadTriggersAndContentChanges()
+ .map { threadDb.unapprovedConversationCount }
- updateJobs.add(
- executor.launch(Dispatchers.IO) {
- context.contentResolver
- .observeQuery(DatabaseContentProviders.ConversationList.CONTENT_URI)
- .onEach { listUpdateChannel.trySend(Unit) }
- .collect()
- }
- )
- updateJobs.add(
- executor.launch(Dispatchers.IO) {
- for (update in listUpdateChannel) {
- threadDb.approvedConversationList.use { openCursor ->
- val reader = threadDb.readerFor(openCursor)
- val threads = mutableListOf()
- while (true) {
- threads += reader.next ?: break
- }
- withContext(Dispatchers.Main) {
- _conversations.value = threads
- }
- }
- }
- }
- )
+ private fun latestUnapprovedConversationTimestamp() = reloadTriggersAndContentChanges()
+ .map { threadDb.latestUnapprovedConversationTimestamp }
+
+ @Suppress("OPT_IN_USAGE")
+ private fun observeConversationList(): Flow> = reloadTriggersAndContentChanges()
+ .mapLatest { _ ->
+ threadDb.approvedConversationList.use { openCursor ->
+ threadDb.readerFor(openCursor).run { generateSequence { next }.toList() }
+ }
}
- return conversations
- }
-}
\ No newline at end of file
+ @OptIn(FlowPreview::class)
+ private fun reloadTriggersAndContentChanges() = merge(
+ manualReloadTrigger,
+ contentResolver.observeChanges(DatabaseContentProviders.ConversationList.CONTENT_URI)
+ )
+ .flowOn(Dispatchers.IO)
+ .debounce(CHANGE_NOTIFICATION_DEBOUNCE_MILLS)
+ .onStart { emit(Unit) }
+
+ fun tryReload() = manualReloadTrigger.tryEmit(Unit)
+
+ data class Data(
+ val threads: List = emptyList(),
+ val typingThreadIDs: Set = emptySet(),
+ val messageRequests: MessageRequests? = null
+ )
+
+ fun createMessageRequests(
+ count: Int,
+ hidden: Boolean,
+ timestamp: Long
+ ) = if (count > 0 && !hidden) MessageRequests(
+ count.toString(),
+ DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), timestamp)
+ ) else null
+
+ data class MessageRequests(val count: String, val timestamp: String)
+
+ companion object {
+ private const val CHANGE_NOTIFICATION_DEBOUNCE_MILLS = 100L
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt
index 2922044435..db0c4d11cc 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathActivity.kt
@@ -6,7 +6,6 @@ import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Bundle
-import android.os.Handler
import android.util.AttributeSet
import android.util.TypedValue
import android.view.Gravity
@@ -17,6 +16,13 @@ import android.widget.TextView
import android.widget.Toast
import androidx.annotation.ColorRes
import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityPathBinding
import org.session.libsession.snode.OnionRequestAPI
@@ -183,6 +189,7 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
private lateinit var location: Location
private var dotAnimationStartDelay: Long = 0
private var dotAnimationRepeatInterval: Long = 0
+ private var job: Job? = null
private val dotView by lazy {
val result = PathDotView(context)
@@ -239,19 +246,38 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
dotViewLayoutParams.addRule(CENTER_IN_PARENT)
dotView.layoutParams = dotViewLayoutParams
addView(dotView)
- Handler().postDelayed({
- performAnimation()
- }, dotAnimationStartDelay)
}
- private fun performAnimation() {
- expand()
- Handler().postDelayed({
- collapse()
- Handler().postDelayed({
- performAnimation()
- }, dotAnimationRepeatInterval)
- }, 1000)
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+
+ startAnimation()
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+
+ stopAnimation()
+ }
+
+ private fun startAnimation() {
+ job?.cancel()
+ job = GlobalScope.launch {
+ withContext(Dispatchers.Main) {
+ while (isActive) {
+ delay(dotAnimationStartDelay)
+ expand()
+ delay(EXPAND_ANIM_DELAY_MILLS)
+ collapse()
+ delay(dotAnimationRepeatInterval)
+ }
+ }
+ }
+ }
+
+ private fun stopAnimation() {
+ job?.cancel()
+ job = null
}
private fun expand() {
@@ -269,6 +295,10 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
val endColor = context.resources.getColorWithID(endColorID, context.theme)
GlowViewUtilities.animateShadowColorChange(dotView, startColor, endColor)
}
+
+ companion object {
+ private const val EXPAND_ANIM_DELAY_MILLS = 1000L
+ }
}
// endregion
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt
index ad8f2d0421..bd38d0df86 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt
@@ -25,7 +25,6 @@ import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.database.ThreadDatabase
-import org.thoughtcrime.securesms.mms.GlideApp
import javax.inject.Inject
@AndroidEntryPoint
@@ -34,6 +33,9 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
@Inject lateinit var threadDb: ThreadDatabase
private lateinit var binding: FragmentUserDetailsBottomSheetBinding
+
+ private var previousContactNickname: String = ""
+
companion object {
const val ARGUMENT_PUBLIC_KEY = "publicKey"
const val ARGUMENT_THREAD_ID = "threadId"
@@ -89,10 +91,10 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
&& !threadRecipient.isOpenGroupInboxRecipient
&& !threadRecipient.isOpenGroupOutboxRecipient
- publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient
+ publicKeyTextView.isVisible = !threadRecipient.isCommunityRecipient
&& !threadRecipient.isOpenGroupInboxRecipient
&& !threadRecipient.isOpenGroupOutboxRecipient
- messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey)?.isBlinded() == true
+ messageButton.isVisible = !threadRecipient.isCommunityRecipient || IdPrefix.fromValue(publicKey)?.isBlinded() == true
publicKeyTextView.text = publicKey
publicKeyTextView.setOnLongClickListener {
val clipboard =
@@ -130,9 +132,10 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
nameTextViewContainer.visibility = View.VISIBLE
nameEditTextContainer.visibility = View.INVISIBLE
var newNickName: String? = null
- if (nicknameEditText.text.isNotEmpty()) {
+ if (nicknameEditText.text.isNotEmpty() && nicknameEditText.text.trim().length != 0) {
newNickName = nicknameEditText.text.toString()
}
+ else { newNickName = previousContactNickname }
val publicKey = recipient.address.serialize()
val storage = MessagingModuleConfiguration.shared.storage
val contact = storage.getContactWithSessionID(publicKey) ?: Contact(publicKey)
@@ -145,6 +148,9 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
fun showSoftKeyboard() {
val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(binding.nicknameEditText, 0)
+
+ // Keep track of the original nickname to re-use if an empty / blank nickname is entered
+ previousContactNickname = binding.nameTextView.text.toString()
}
fun hideSoftKeyboard() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt
index 1537769cdc..c22ccde1f1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt
@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.home.search
import android.content.Context
import android.text.Editable
+import android.text.InputFilter
+import android.text.InputFilter.LengthFilter
import android.text.TextWatcher
import android.util.AttributeSet
import android.view.KeyEvent
@@ -34,6 +36,7 @@ class GlobalSearchInputLayout @JvmOverloads constructor(
binding.searchInput.onFocusChangeListener = this
binding.searchInput.addTextChangedListener(this)
binding.searchInput.setOnEditorActionListener(this)
+ binding.searchInput.setFilters( arrayOf(LengthFilter(100)) ) // 100 char search limit
binding.searchCancel.setOnClickListener(this)
binding.searchClear.setOnClickListener(this)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt
index 8908554b03..1ff0a395fe 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt
@@ -24,8 +24,7 @@ class GlobalSearchViewModel @Inject constructor(private val searchRepository: Se
private val executor = viewModelScope + SupervisorJob()
- private val _result: MutableStateFlow =
- MutableStateFlow(GlobalSearchResult.EMPTY)
+ private val _result: MutableStateFlow = MutableStateFlow(GlobalSearchResult.EMPTY)
val result: StateFlow = _result
@@ -41,13 +40,14 @@ class GlobalSearchViewModel @Inject constructor(private val searchRepository: Se
_queryText
.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST)
.mapLatest { query ->
- if (query.trim().length < 2) {
+ // Early exit on empty search query
+ if (query.trim().isEmpty()) {
SearchResult.EMPTY
} else {
- // user input delay here in case we get a new query within a few hundred ms
- // this coroutine will be cancelled and expensive query will not be run if typing quickly
- // first query of 2 characters will be instant however
+ // User input delay in case we get a new query within a few hundred ms this
+ // coroutine will be cancelled and the expensive query will not be run.
delay(300)
+
val settableFuture = SettableFuture()
searchRepository.query(query.toString(), settableFuture::set)
try {
@@ -64,6 +64,4 @@ class GlobalSearchViewModel @Inject constructor(private val searchRepository: Se
}
.launchIn(executor)
}
-
-
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java
index 697f6718c2..6dcc928c99 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java
@@ -1,5 +1,7 @@
package org.thoughtcrime.securesms.linkpreview;
+import static org.session.libsession.utilities.Util.readFully;
+
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
@@ -8,8 +10,6 @@ import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import com.google.android.gms.common.util.IOUtils;
-
import org.session.libsession.messaging.sending_receiving.attachments.Attachment;
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress;
import org.session.libsession.messaging.sending_receiving.attachments.UriAttachment;
@@ -148,7 +148,7 @@ public class LinkPreviewRepository {
InputStream bodyStream = response.body().byteStream();
controller.setStream(bodyStream);
- byte[] data = IOUtils.readInputStreamFully(bodyStream);
+ byte[] data = readFully(bodyStream);
Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length);
Optional thumbnail = bitmapToAttachment(bitmap, Bitmap.CompressFormat.JPEG, MediaTypes.IMAGE_JPEG);
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/mediasend/MediaSendFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java
index eac40f6818..cba1529a51 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java
@@ -37,7 +37,7 @@ import org.thoughtcrime.securesms.components.emoji.EmojiEventListener;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
-import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
+import org.thoughtcrime.securesms.util.SimpleTextWatcher;
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java
index 0a24c26fad..02172b7248 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SignalGlideModule.java
@@ -73,7 +73,7 @@ public class SignalGlideModule extends AppGlideModule {
registry.append(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory(context));
registry.append(AttachmentModel.class, InputStream.class, new AttachmentStreamUriLoader.Factory());
registry.append(ChunkedImageUrl.class, InputStream.class, new ChunkedImageUrlLoader.Factory());
- registry.append(PlaceholderAvatarPhoto.class, BitmapDrawable.class, new PlaceholderAvatarLoader.Factory());
+ registry.append(PlaceholderAvatarPhoto.class, BitmapDrawable.class, new PlaceholderAvatarLoader.Factory(context));
registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory());
}
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/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java
index 0157d8ad41..b281e0798b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java
@@ -54,7 +54,7 @@ import org.session.libsignal.utilities.IdPrefix;
import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.Util;
import org.thoughtcrime.securesms.ApplicationContext;
-import org.thoughtcrime.securesms.contactshare.ContactUtil;
+import org.thoughtcrime.securesms.contacts.ContactUtil;
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2;
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities;
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities;
@@ -349,11 +349,17 @@ public class DefaultMessageNotifier implements MessageNotifier {
builder.setThread(notifications.get(0).getRecipient());
builder.setMessageCount(notificationState.getMessageCount());
MentionManagerUtilities.INSTANCE.populateUserPublicKeyCacheIfNeeded(notifications.get(0).getThreadId(),context);
+
+ // TODO: Removing highlighting mentions in the notification because this context is the libsession one which
+ // TODO: doesn't have access to the `R.attr.message_sent_text_color` and `R.attr.message_received_text_color`
+ // TODO: attributes to perform the colour lookup. Also, it makes little sense to highlight the mentions using
+ // TODO: the app theme as it may result in insufficient contrast with the notification background which will
+ // TODO: be using the SYSTEM theme.
builder.setPrimaryMessageBody(recipient, notifications.get(0).getIndividualRecipient(),
- MentionUtilities.highlightMentions(text == null ? "" : text,
- notifications.get(0).getThreadId(),
- context),
+ //MentionUtilities.highlightMentions(text == null ? "" : text, notifications.get(0).getThreadId(), context), // Removing hightlighting mentions -ACL
+ text == null ? "" : text,
notifications.get(0).getSlideDeck());
+
builder.setContentIntent(notifications.get(0).getPendingIntent(context));
builder.setDeleteIntent(notificationState.getDeleteIntent(context));
builder.setOnlyAlertOnce(!signal);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/FcmUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/FcmUtils.kt
deleted file mode 100644
index 87a9efc0de..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/FcmUtils.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-@file:JvmName("FcmUtils")
-package org.thoughtcrime.securesms.notifications
-
-import com.google.android.gms.tasks.Task
-import com.google.firebase.iid.FirebaseInstanceId
-import com.google.firebase.iid.InstanceIdResult
-import kotlinx.coroutines.*
-
-
-fun getFcmInstanceId(body: (Task)->Unit): Job = MainScope().launch(Dispatchers.IO) {
- val task = FirebaseInstanceId.getInstance().instanceId
- while (!task.isComplete && isActive) {
- // wait for task to complete while we are active
- }
- if (!isActive) return@launch // don't 'complete' task if we were canceled
- withContext(Dispatchers.Main) {
- body(task)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt
deleted file mode 100644
index adaec0e17a..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt
+++ /dev/null
@@ -1,121 +0,0 @@
-package org.thoughtcrime.securesms.notifications
-
-import android.content.Context
-import nl.komponents.kovenant.Promise
-import nl.komponents.kovenant.functional.map
-import okhttp3.MediaType
-import okhttp3.Request
-import okhttp3.RequestBody
-import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI
-import org.session.libsession.snode.OnionRequestAPI
-import org.session.libsession.snode.Version
-import org.session.libsession.utilities.TextSecurePreferences
-import org.session.libsignal.utilities.JsonUtil
-import org.session.libsignal.utilities.Log
-import org.session.libsignal.utilities.retryIfNeeded
-import org.thoughtcrime.securesms.dependencies.DatabaseComponent
-
-object LokiPushNotificationManager {
- private val maxRetryCount = 4
- private val tokenExpirationInterval = 12 * 60 * 60 * 1000
-
- private val server by lazy {
- PushNotificationAPI.server
- }
- private val pnServerPublicKey by lazy {
- PushNotificationAPI.serverPublicKey
- }
-
- enum class ClosedGroupOperation {
- Subscribe, Unsubscribe;
-
- val rawValue: String
- get() {
- return when (this) {
- Subscribe -> "subscribe_closed_group"
- Unsubscribe -> "unsubscribe_closed_group"
- }
- }
- }
-
- @JvmStatic
- fun unregister(token: String, context: Context) {
- val parameters = mapOf( "token" to token )
- val url = "$server/unregister"
- val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
- val request = Request.Builder().url(url).post(body)
- retryIfNeeded(maxRetryCount) {
- getResponseBody(request.build()).map { json ->
- val code = json["code"] as? Int
- if (code != null && code != 0) {
- TextSecurePreferences.setIsUsingFCM(context, false)
- } else {
- Log.d("Loki", "Couldn't disable FCM due to error: ${json["message"] as? String ?: "null"}.")
- }
- }.fail { exception ->
- Log.d("Loki", "Couldn't disable FCM due to error: ${exception}.")
- }
- }
- // Unsubscribe from all closed groups
- val allClosedGroupPublicKeys = DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys()
- val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
- allClosedGroupPublicKeys.iterator().forEach { closedGroup ->
- performOperation(context, ClosedGroupOperation.Unsubscribe, closedGroup, userPublicKey)
- }
- }
-
- @JvmStatic
- fun register(token: String, publicKey: String, context: Context, force: Boolean) {
- val oldToken = TextSecurePreferences.getFCMToken(context)
- val lastUploadDate = TextSecurePreferences.getLastFCMUploadTime(context)
- if (!force && token == oldToken && System.currentTimeMillis() - lastUploadDate < tokenExpirationInterval) { return }
- val parameters = mapOf( "token" to token, "pubKey" to publicKey )
- val url = "$server/register"
- val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
- val request = Request.Builder().url(url).post(body)
- retryIfNeeded(maxRetryCount) {
- getResponseBody(request.build()).map { json ->
- val code = json["code"] as? Int
- if (code != null && code != 0) {
- TextSecurePreferences.setIsUsingFCM(context, true)
- TextSecurePreferences.setFCMToken(context, token)
- TextSecurePreferences.setLastFCMUploadTime(context, System.currentTimeMillis())
- } else {
- Log.d("Loki", "Couldn't register for FCM due to error: ${json["message"] as? String ?: "null"}.")
- }
- }.fail { exception ->
- Log.d("Loki", "Couldn't register for FCM due to error: ${exception}.")
- }
- }
- // Subscribe to all closed groups
- val allClosedGroupPublicKeys = DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys()
- allClosedGroupPublicKeys.iterator().forEach { closedGroup ->
- performOperation(context, ClosedGroupOperation.Subscribe, closedGroup, publicKey)
- }
- }
-
- @JvmStatic
- fun performOperation(context: Context, operation: ClosedGroupOperation, closedGroupPublicKey: String, publicKey: String) {
- if (!TextSecurePreferences.isUsingFCM(context)) { return }
- val parameters = mapOf( "closedGroupPublicKey" to closedGroupPublicKey, "pubKey" to publicKey )
- val url = "$server/${operation.rawValue}"
- val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters))
- val request = Request.Builder().url(url).post(body)
- retryIfNeeded(maxRetryCount) {
- getResponseBody(request.build()).map { json ->
- val code = json["code"] as? Int
- if (code == null || code == 0) {
- Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${json["message"] as? String ?: "null"}.")
- }
- }.fail { exception ->
- Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${exception}.")
- }
- }
- }
-
- private fun getResponseBody(request: Request): Promise