diff --git a/.drone.jsonnet b/.drone.jsonnet new file mode 100644 index 0000000000..dc81115ce9 --- /dev/null +++ b/.drone.jsonnet @@ -0,0 +1,88 @@ +local docker_base = 'registry.oxen.rocks/lokinet-ci-'; + +// Log a bunch of version information to make it easier for debugging +local version_info = { + name: 'Version Information', + image: docker_base + 'android', + commands: [ + 'cmake --version', + 'apt --installed list' + ] +}; + + +// Intentionally doing a depth of 2 as libSession-util has it's own submodules (and libLokinet likely will as well) +local clone_submodules = { + name: 'Clone Submodules', + image: 'drone/git', + commands: ['git fetch --tags', 'git submodule update --init --recursive --depth=2 --jobs=4'] +}; + +// cmake options for static deps mirror +local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https://oxen.rocks/deps ' else ''); + +[ + // Unit tests (PRs only) + { + kind: 'pipeline', + type: 'docker', + name: 'Unit Tests', + platform: { arch: 'amd64' }, + trigger: { event: { exclude: [ 'push' ] } }, + steps: [ + version_info, + clone_submodules, + { + name: 'Run Unit Tests', + image: docker_base + 'android', + pull: 'always', + environment: { ANDROID_HOME: '/usr/lib/android-sdk' }, + commands: [ + 'apt-get install -y ninja-build', + './gradlew testPlayDebugUnitTestCoverageReport' + ], + } + ], + }, + // Validate build artifact was created by the direct branch push (PRs only) + { + kind: 'pipeline', + type: 'docker', + name: 'Check Build Artifact Existence', + platform: { arch: 'amd64' }, + trigger: { event: { exclude: [ 'push' ] } }, + steps: [ + { + name: 'Poll for build artifact existence', + image: docker_base + 'android', + pull: 'always', + commands: [ + './scripts/drone-upload-exists.sh' + ] + } + ] + }, + // Debug APK build (non-PRs only) + { + kind: 'pipeline', + type: 'docker', + name: 'Debug APK Build', + platform: { arch: 'amd64' }, + trigger: { event: { exclude: [ 'pull_request' ] } }, + steps: [ + version_info, + clone_submodules, + { + name: 'Build and upload', + image: docker_base + 'android', + pull: 'always', + environment: { SSH_KEY: { from_secret: 'SSH_KEY' }, ANDROID_HOME: '/usr/lib/android-sdk' }, + commands: [ + 'apt-get install -y ninja-build', + './gradlew assemblePlayDebug', + './scripts/drone-static-upload.sh' + ], + } + ], + } +] \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 7c17252c2d..0000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,45 +0,0 @@ - - -- [ ] I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-android/blob/master/CODE_OF_CONDUCT.md). -- [ ] I have searched open and closed issues for duplicates -- [ ] I am submitting a bug report for existing functionality that does not work as intended -- [ ] This isn't a feature request or a discussion topic - ----------------------------------------- - -### Bug description -Describe here the issue that you are experiencing. - -### Steps to reproduce -- using hyphens as bullet points -- list the steps -- that reproduce the bug - -**Actual result:** - -Describe here what happens after you run the steps above (i.e. the buggy behaviour) - -**Expected result:** - -Describe here what should happen after you run the steps above (i.e. what would be the correct behaviour) - -### Screenshots - - -### Device info - - -**Device:** Manufacturer Model XVI - -**Android version:** 0.0.0 - -**Session version:** 0.0.0 - -### Link to debug log diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 74bbafd0f6..0000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug -assignees: '' - ---- - -**Code of conduct** - -- [ ] I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-android/blob/master/CODE_OF_CONDUCT.md). - -**Describe the bug** - -A clear and concise description of what the bug is. - -**To reproduce** - -Steps to reproduce the behavior: - -**Screenshots or logs** - -If applicable, add screenshots or logs to help explain your problem. - -**Smartphone (please complete the following information):** - - - Device: [e.g. Samsung Galaxy S8] - - OS: [e.g. Android Pie] - - Version of Session or latest commit hash - -**Additional context** - -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..883f792482 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,74 @@ +name: 🐞 Bug Report +description: Create a report to help us improve +title: "[BUG] " +labels: [bug] +body: +- type: checkboxes + attributes: + label: Code of conduct + description: I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-android/blob/master/CODE_OF_CONDUCT.md). + options: + - label: I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-android/blob/master/CODE_OF_CONDUCT.md) + required: true + +- type: checkboxes + attributes: + label: Self-training on how to write a bug report + description: High quality bug reports can help the team save time and improve the chance of getting your issue fixed. Please read [how to write a bug report](https://www.browserstack.com/guide/how-to-write-a-bug-report) before submitting your issue. + options: + - label: I have learned [how to write a bug report](https://www.browserstack.com/guide/how-to-write-a-bug-report) + required: true + +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered. + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: Current Behavior + description: A concise description of what you're experiencing. + validations: + required: false +- type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: false +- type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. In this environment... + 2. With this config... + 3. Run '...' + 4. See error... + validations: + required: false +- type: input + attributes: + label: Android Version + description: What version of Android are you running? + placeholder: ex. Android 11 + validations: + required: false +- type: input + attributes: + label: Session Version + description: What version of Session are you running? (This can be found at the bottom of the app settings) + placeholder: ex. 1.17.0 (3425) + validations: + required: false +- type: textarea + attributes: + label: Anything else? + description: | + Add any other context about the problem here. + + Tip: You can attach screenshots or log files to help explain your problem by clicking this area to highlight it and then dragging files in. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..3c9712e52a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,26 @@ +name: 🚀 Feature request +description: Suggest an idea for Session +title: '[Feature] <title>' +labels: [feature-request] +body: +- type: checkboxes + attributes: + label: Is there an existing request for feature? + description: Please search to see if an issue already exists for the feature you are requesting. + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: What feature would you like? + description: | + A clear and concise description of the feature you would like added to Session + validations: + required: true +- type: textarea + attributes: + label: Anything else? + description: | + Add any other context or screenshots about the feature request here + validations: + required: false diff --git a/.gitignore b/.gitignore index 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 @@ +<component name="ProjectRunConfigurationManager"> + <configuration default="false" name="Run Tests" type="GradleRunConfiguration" factoryName="Gradle"> + <ExternalSystemSettings> + <option name="executionName" /> + <option name="externalProjectPath" value="$PROJECT_DIR$" /> + <option name="externalSystemIdString" value="GRADLE" /> + <option name="scriptParameters" value="" /> + <option name="taskDescriptions"> + <list /> + </option> + <option name="taskNames"> + <list> + <option value="testPlayDebugUnitTestCoverageReport" /> + </list> + </option> + <option name="vmOptions" /> + </ExternalSystemSettings> + <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess> + <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess> + <DebugAllEnabled>false</DebugAllEnabled> + <RunAsTest>false</RunAsTest> + <method v="2" /> + </configuration> +</component> \ 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<Contact>): ByteArray { val (key,_) = maybeGetUserInfo()!! - val contacts = Contacts.Companion.newInstance(key) + val contacts = Contacts.newInstance(key) contactList.forEach { contact -> contacts.set(contact) } return contacts.push().config } + private fun buildVolatileMessage(conversations: List<Conversation>): ByteArray { + val (key, _) = maybeGetUserInfo()!! + val volatile = ConversationVolatileConfig.newInstance(key) + conversations.forEach { conversation -> + volatile.set(conversation) + } + return volatile.push().config + } + private fun fakePollNewConfig(configBase: ConfigBase, toMerge: ByteArray) { configBase.merge(nextFakeHash to toMerge) MessagingModuleConfiguration.shared.configFactory.persist(configBase, System.currentTimeMillis()) @@ -95,8 +113,83 @@ class LibSessionTests { fakePollNewConfig(contacts, newContactMerge) verify(storageSpy).addLibSessionContacts(argThat { first().let { it.id == newContactId && it.approved } && size == 1 - }) + }, any()) verify(storageSpy).setRecipientApproved(argThat { address.serialize() == newContactId }, eq(true)) } + @Test + fun test_expected_configs() { + val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + val storageSpy = spy(app.storage) + app.storage = storageSpy + + val randomRecipient = randomSessionId() + val newContact = Contact( + id = randomRecipient, + approved = true, + expiryMode = ExpiryMode.AfterSend(1000) + ) + val newConvo = Conversation.OneToOne( + randomRecipient, + SnodeAPI.nowWithOffset, + false + ) + val volatiles = MessagingModuleConfiguration.shared.configFactory.convoVolatile!! + val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!! + val newContactMerge = buildContactMessage(listOf(newContact)) + val newVolatileMerge = buildVolatileMessage(listOf(newConvo)) + fakePollNewConfig(contacts, newContactMerge) + fakePollNewConfig(volatiles, newVolatileMerge) + verify(storageSpy).setExpirationConfiguration(argWhere { config -> + config.expiryMode is ExpiryMode.AfterSend + && config.expiryMode.expirySeconds == 1000L + }) + val threadId = storageSpy.getThreadId(Address.fromSerialized(randomRecipient))!! + val newExpiry = storageSpy.getExpirationConfiguration(threadId)!! + assertThat(newExpiry.expiryMode, instanceOf(ExpiryMode.AfterSend::class.java)) + assertThat(newExpiry.expiryMode.expirySeconds, equalTo(1000)) + assertThat(newExpiry.expiryMode.expiryMillis, equalTo(1000000)) + } + + @Test + fun test_overwrite_config() { + val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + val storageSpy = spy(app.storage) + app.storage = storageSpy + + // Initial state + val randomRecipient = randomSessionId() + val currentContact = Contact( + id = randomRecipient, + approved = true, + expiryMode = ExpiryMode.NONE + ) + val newConvo = Conversation.OneToOne( + randomRecipient, + SnodeAPI.nowWithOffset, + false + ) + val volatiles = MessagingModuleConfiguration.shared.configFactory.convoVolatile!! + val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!! + val newContactMerge = buildContactMessage(listOf(currentContact)) + val newVolatileMerge = buildVolatileMessage(listOf(newConvo)) + fakePollNewConfig(contacts, newContactMerge) + fakePollNewConfig(volatiles, newVolatileMerge) + verify(storageSpy).setExpirationConfiguration(argWhere { config -> + config.expiryMode == ExpiryMode.NONE + }) + val threadId = storageSpy.getThreadId(Address.fromSerialized(randomRecipient))!! + val currentExpiryConfig = storageSpy.getExpirationConfiguration(threadId)!! + assertThat(currentExpiryConfig.expiryMode, equalTo(ExpiryMode.NONE)) + assertThat(currentExpiryConfig.expiryMode.expirySeconds, equalTo(0)) + assertThat(currentExpiryConfig.expiryMode.expiryMillis, equalTo(0)) + // Set new state and overwrite + val updatedContact = currentContact.copy(expiryMode = ExpiryMode.AfterSend(1000)) + val updateContactMerge = buildContactMessage(listOf(updatedContact)) + fakePollNewConfig(contacts, updateContactMerge) + val updatedExpiryConfig = storageSpy.getExpirationConfiguration(threadId)!! + assertThat(updatedExpiryConfig.expiryMode, instanceOf(ExpiryMode.AfterSend::class.java)) + assertThat(updatedExpiryConfig.expiryMode.expirySeconds, equalTo(1000)) + } + } \ No newline at end of file diff --git a/app/src/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 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools"> + + <application tools:node="merge"> + <meta-data + android:name="com.huawei.hms.client.appid" + android:value="appid=107205081"> + </meta-data> + + <meta-data + android:name="com.huawei.hms.client.cpid" + android:value="cpid=30061000024605000"> + </meta-data> + + <service + android:name="org.thoughtcrime.securesms.notifications.HuaweiPushService" + android:enabled="true" + android:exported="false"> + <intent-filter> + <action android:name="com.huawei.push.action.MESSAGING_EVENT" /> + </intent-filter> + </service> + </application> + +</manifest> 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<PushRegistry>, +): 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="preferences_notifications_strategy_category_fast_mode_summary">You\'ll be notified of new messages reliably and immediately using Huawei’s notification servers.</string> + <string name="activity_pn_mode_fast_mode_explanation">You\'ll be notified of new messages reliably and immediately using Huawei’s notification servers.</string> +</resources> \ 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 @@ <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.USE_FINGERPRINT" /> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/> <uses-permission android:name="network.loki.messenger.ACCESS_SESSION_SECRETS" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/> + <uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> @@ -104,11 +107,6 @@ android:name="org.thoughtcrime.securesms.onboarding.RegisterActivity" android:screenOrientation="portrait" android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> - <activity - android:name="org.thoughtcrime.securesms.onboarding.RecoveryPhraseRestoreActivity" - android:screenOrientation="portrait" - android:windowSoftInputMode="adjustResize" - android:theme="@style/Theme.Session.DayNight.FlatActionBar" /> <activity android:name="org.thoughtcrime.securesms.onboarding.LinkDeviceActivity" android:screenOrientation="portrait" @@ -176,6 +174,9 @@ android:screenOrientation="portrait" /> <activity android:name="org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity" android:screenOrientation="portrait"/> + <activity android:name="org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity" + android:screenOrientation="portrait" + android:theme="@style/Theme.Session.DayNight.NoActionBar" /> <activity android:exported="true" @@ -225,20 +226,18 @@ android:name="org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2" android:screenOrientation="portrait" android:parentActivityName="org.thoughtcrime.securesms.home.HomeActivity" - android:theme="@style/Theme.Session.DayNight.NoActionBar"> + android:theme="@style/Theme.Session.DayNight.NoActionBar" + android:windowSoftInputMode="adjustResize" > <meta-data android:name="android.support.PARENT_ACTIVITY" android:value="org.thoughtcrime.securesms.home.HomeActivity" /> </activity> + <activity android:name="org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity" android:screenOrientation="portrait" android:theme="@style/Theme.Session.DayNight"> </activity> - <activity - android:name="org.thoughtcrime.securesms.longmessage.LongMessageActivity" - android:screenOrientation="portrait" - android:theme="@style/Theme.Session.DayNight" /> <activity android:name="org.thoughtcrime.securesms.DatabaseUpgradeActivity" android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" @@ -310,20 +309,16 @@ android:name="android.support.PARENT_ACTIVITY" android:value="org.thoughtcrime.securesms.home.HomeActivity" /> </activity> - <service - android:name="org.thoughtcrime.securesms.notifications.PushNotificationService" - android:enabled="true" - android:exported="false"> - <intent-filter> - <action android:name="com.google.firebase.MESSAGING_EVENT" /> - </intent-filter> - </service> <service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService" + android:foregroundServiceType="microphone" android:exported="false" /> <service android:name="org.thoughtcrime.securesms.service.KeyCachingService" android:enabled="true" - android:exported="false" /> + android:exported="false" android:foregroundServiceType="specialUse"> +<!-- <property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"--> +<!-- android:value="@string/preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint"/>--> + </service> <service android:name="org.thoughtcrime.securesms.service.DirectShareService" android:exported="true" diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt b/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt index 2588618b72..b9183939cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/AppContext.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor import nl.komponents.kovenant.Kovenant import nl.komponents.kovenant.jvm.asDispatcher import org.session.libsignal.utilities.Log @@ -11,7 +13,7 @@ object AppContext { fun configureKovenant() { Kovenant.context { callbackContext.dispatcher = Executors.newSingleThreadExecutor().asDispatcher() - workerContext.dispatcher = ThreadUtils.executorPool.asDispatcher() + workerContext.dispatcher = Dispatchers.IO.asExecutor().asDispatcher() multipleCompletion = { v1, v2 -> 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<MessageRecord> previousMessageRecord, - @NonNull Optional<MessageRecord> nextMessageRecord, - @NonNull GlideRequests glideRequests, - @NonNull Locale locale, - @NonNull Set<MessageRecord> 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<NumberPickerView>(R.id.expiration_number_picker) - - fun updateText(index: Int) { - view.findViewById<TextView>(R.id.expiration_details).text = when (index) { - 0 -> getString(R.string.ExpirationDialog_your_messages_will_not_expire) - else -> getString( - R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen, - numberPickerView.displayedValues[index] - ) - } - } - - val expirationTimes = resources.getIntArray(R.array.expiration_times) - val expirationDisplayValues = expirationTimes - .map { ExpirationUtil.getExpirationDisplayValue(this, it) } - .toTypedArray() - - val selectedIndex = expirationTimes.run { indexOfFirst { it >= expiration }.coerceIn(indices) } - - numberPickerView.apply { - displayedValues = expirationDisplayValues - minValue = 0 - maxValue = expirationTimes.lastIndex - setOnValueChangedListener { _, _, index -> updateText(index) } - value = selectedIndex - } - - updateText(selectedIndex) - - return showSessionDialog { - title(getString(R.string.ExpirationDialog_disappearing_messages)) - view(view) - okButton { onExpirationTime(numberPickerView.let { expirationTimes[it.value] }) } - cancelButton() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index f19a1fc45e..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<Pair<Cursor, Integer>> loader, @Nullable Pair<Cursor, Integer> 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<Integer, MediaView> 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<RecipientDeliveryStatus> members; - private final boolean isPushGroup; - - MessageDetailsRecipientAdapter(@NonNull Context context, @NonNull GlideRequests glideRequests, - @NonNull MessageRecord record, @NonNull List<RecipientDeliveryStatus> 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<Long>, 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<Void, Void, Void>() { - @Override - protected Void doInBackground(Void... params) { - ExpiringMessageManager expirationManager = ApplicationContext.getInstance(getContext()).getExpiringMessageManager(); - long id = messageRecord.getId(); - boolean mms = messageRecord.isMms(); - - if (mms) DatabaseComponent.get(getContext()).mmsDatabase().markExpireStarted(id); - else DatabaseComponent.get(getContext()).smsDatabase().markExpireStarted(id); - - expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn()); - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - } else { - this.timerView.setVisibility(View.GONE); - } - } - - private void presentInsecureIndicator(@NonNull MessageRecord messageRecord) { - insecureIndicatorView.setVisibility(View.GONE); - } - - private void presentDeliveryStatus(@NonNull MessageRecord messageRecord) { - if (!messageRecord.isFailed()) { - if (!messageRecord.isOutgoing()) deliveryStatusView.setNone(); - else if (messageRecord.isPending()) deliveryStatusView.setPending(); - else if (messageRecord.isRead()) deliveryStatusView.setRead(); - else if (messageRecord.isDelivered()) deliveryStatusView.setDelivered(); - else deliveryStatusView.setSent(); - } else { - deliveryStatusView.setNone(); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/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<String, String?>() + private val profilePicturesCache = mutableMapOf<View, Recipient>() 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<String> 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<String> recentlyUsed; + public static final LinkedList<String> 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<String> recentlyUsed; public RecentEmojiPageModel(Context context) { - this.prefs = PreferenceManager.getDefaultSharedPreferences(context); - this.recentlyUsed = getPersistedCache(); - } + prefs = PreferenceManager.getDefaultSharedPreferences(context); - private LinkedHashSet<String> 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<String> getEmoji() { - List<String> recent = new ArrayList<>(recentlyUsed); - List<String> 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<Emoji> 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<String> 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<String> latestRecentlyUsed = new LinkedHashSet<>(recentlyUsed); - new AsyncTask<Void, Void, Void>() { + // 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<String> 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<Bitmap> bitmapReference; - private ListenableFutureTask<Bitmap> 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<Bitmap> get() { - Util.assertMainThread(); - - if (bitmapReference != null && bitmapReference.get() != null) { - return new ListenableFutureTask<>(bitmapReference.get()); - } else if (task != null) { - return task; - } else { - Callable<Bitmap> 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<Void, Void, Void>() { - @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<ActionItem>.toAdapterItems(): List<DisplayItem> { - return this.mapIndexed { index, item -> - val displayType: DisplayType = when { - this.size == 1 -> DisplayType.ONLY + private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> = + mapIndexed { index, item -> + when { + size == 1 -> DisplayType.ONLY index == 0 -> DisplayType.TOP - index == this.size - 1 -> DisplayType.BOTTOM + index == size - 1 -> DisplayType.BOTTOM else -> DisplayType.MIDDLE - } - - DisplayItem(item, displayType) + }.let { DisplayItem(item, it) } } - } private data class DisplayItem( val item: ActionItem, val displayType: DisplayType ) : MappingModel<DisplayItem> { - override fun areItemsTheSame(newItem: DisplayItem): Boolean { - return this == newItem - } + override fun areItemsTheSame(newItem: DisplayItem): Boolean = this == newItem - override fun areContentsTheSame(newItem: DisplayItem): Boolean { - return this == newItem - } + override fun areContentsTheSame(newItem: DisplayItem): Boolean = this == newItem } private enum class DisplayType { @@ -68,28 +71,61 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) { itemView: View, private val onItemClick: () -> Unit, ) : MappingViewHolder<DisplayItem>(itemView) { + private var subtitleJob: Job? = null val icon: ImageView = itemView.findViewById(R.id.context_menu_item_icon) val title: TextView = itemView.findViewById(R.id.context_menu_item_title) + val subtitle: TextView = itemView.findViewById(R.id.context_menu_item_subtitle) override fun bind(model: DisplayItem) { - if (model.item.iconRes > 0) { + val item = model.item + val color = item.color?.let { ContextCompat.getColor(context, it) } + + if (item.iconRes > 0) { val typedValue = TypedValue() - context.theme.resolveAttribute(model.item.iconRes, typedValue, true) + context.theme.resolveAttribute(item.iconRes, typedValue, true) icon.setImageDrawable(ContextCompat.getDrawable(context, typedValue.resourceId)) + + icon.imageTintList = 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<Recipient>): List<ContactSelectionListItem> { 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<SharedContact.Phone> phoneNumbers = new ArrayList<>(contact.getPhoneNumbers().size()); - List<SharedContact.Email> emails = new ArrayList<>(contact.getEmails().size()); - List<SharedContact.PostalAddress> 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<Phone> 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<Email> 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<PostalAddress> 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<MarginLayoutParams> { + marginEnd = if (recipient.showCallMenu()) 0 else binding.profilePictureView.width + } + } + + fun updateSubtitle(recipient: Recipient, openGroup: OpenGroup? = null, config: ExpirationConfiguration? = null) { + val settings = mutableListOf<ConversationSetting>() + if (config?.isEnabled == true) { + val prefix = when (config.expiryMode) { + is ExpiryMode.AfterRead -> R.string.expiration_type_disappear_after_read + else -> R.string.expiration_type_disappear_after_send + }.let(context::getString) + settings += ConversationSetting( + "$prefix - ${ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, config.expiryMode.expirySeconds)}", + ConversationSettingType.EXPIRATION, + R.drawable.ic_timer, + resources.getString(R.string.AccessibilityId_disappearing_messages_type_and_time) + ) + } + if (recipient.isMuted) { + settings += ConversationSetting( + recipient.mutedUntil.takeUnless { it == Long.MAX_VALUE } + ?.let { context.getString(R.string.ConversationActivity_muted_until_date, DateUtils.getFormattedDateTime(it, "EEE, MMM d, yyyy HH:mm", Locale.getDefault())) } + ?: context.getString(R.string.ConversationActivity_muted_forever), + ConversationSettingType.NOTIFICATION, + R.drawable.ic_outline_notifications_off_24 + ) + } + if (recipient.isGroupRecipient) { + val title = if (recipient.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<ConversationSetting, ConversationSettingsAdapter.SettingViewHolder>(SettingsDiffer()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + return SettingViewHolder(ViewConversationSettingBinding.inflate(layoutInflater, parent, false)) + } + + override fun onBindViewHolder(holder: SettingViewHolder, position: Int) { + holder.bind(getItem(position), itemCount) { + settingsListener.invoke(it) + } + } + + class SettingViewHolder( + private val binding: ViewConversationSettingBinding + ): RecyclerView.ViewHolder(binding.root) { + + fun bind(setting: ConversationSetting, itemCount: Int, listener: (ConversationSetting) -> Unit) { + binding.root.setOnClickListener { listener.invoke(setting) } + binding.root.contentDescription = setting.contentDescription + binding.iconImageView.setImageResource(setting.iconResId) + binding.iconImageView.isVisible = setting.iconResId > 0 + binding.titleView.text = setting.title + binding.leftArrowImageView.isVisible = itemCount > 1 + binding.rightArrowImageView.isVisible = itemCount > 1 + } + } + + class SettingsDiffer: DiffUtil.ItemCallback<ConversationSetting>() { + override fun areItemsTheSame(oldItem: ConversationSetting, newItem: ConversationSetting): Boolean = oldItem.settingType === newItem.settingType + override fun areContentsTheSame(oldItem: ConversationSetting, newItem: ConversationSetting): Boolean = oldItem == newItem + } + } +} + +fun interface ConversationActionBarDelegate { + fun onDisappearingMessagesClicked() +} + +data class ConversationSetting( + val title: String, + val settingType: ConversationSettingType, + val iconResId: Int = 0, + val contentDescription: String = "" +) + +enum class ConversationSettingType { + EXPIRATION, + MEMBER_COUNT, + NOTIFICATION +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt new file mode 100644 index 0000000000..d336c967ce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ExpirationUtil +import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.getExpirationTypeDisplayValue +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +class DisappearingMessages @Inject constructor( + @ApplicationContext private val context: Context, + private val textSecurePreferences: TextSecurePreferences, + private val messageExpirationManager: MessageExpirationManagerProtocol, +) { + fun set(threadId: Long, address: Address, mode: ExpiryMode, isGroup: Boolean) { + val expiryChangeTimestampMs = SnodeAPI.nowWithOffset + MessagingModuleConfiguration.shared.storage.setExpirationConfiguration(ExpirationConfiguration(threadId, mode, expiryChangeTimestampMs)) + + val message = ExpirationTimerUpdate(isGroup = isGroup).apply { + expiryMode = mode + sender = textSecurePreferences.getLocalNumber() + isSenderSelf = true + recipient = address.serialize() + sentTimestamp = expiryChangeTimestampMs + } + + messageExpirationManager.insertExpirationTimerMessage(message) + MessageSender.send(message, address) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + + fun showFollowSettingDialog(context: Context, message: MessageRecord) = context.showSessionDialog { + title(R.string.dialog_disappearing_messages_follow_setting_title) + text(if (message.expiresIn == 0L) { + context.getString(R.string.dialog_disappearing_messages_follow_setting_off_body) + } else { + context.getString( + R.string.dialog_disappearing_messages_follow_setting_on_body, + ExpirationUtil.getExpirationDisplayValue( + context, + message.expiresIn.milliseconds + ), + context.getExpirationTypeDisplayValue(message.isNotDisappearAfterRead) + ) + }) + destructiveButton( + text = if (message.expiresIn == 0L) R.string.dialog_disappearing_messages_follow_setting_confirm else R.string.dialog_disappearing_messages_follow_setting_set, + contentDescription = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_set_button + ) { + set(message.threadId, message.recipient.address, message.expiryMode, message.recipient.isClosedGroupRecipient) + } + cancelButton() + } +} + +val MessageRecord.expiryMode get() = if (expiresIn <= 0) ExpiryMode.NONE + else if (expireStarted == timestamp) ExpiryMode.AfterSend(expiresIn / 1000) + else ExpiryMode.AfterRead(expiresIn / 1000) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt new file mode 100644 index 0000000000..16e74cdde9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt @@ -0,0 +1,94 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import network.loki.messenger.R +import network.loki.messenger.databinding.ActivityDisappearingMessagesBinding +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessages +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.ui.AppTheme +import javax.inject.Inject + +@AndroidEntryPoint +class DisappearingMessagesActivity: PassphraseRequiredActionBarActivity() { + + private lateinit var binding : ActivityDisappearingMessagesBinding + + @Inject lateinit var recipientDb: RecipientDatabase + @Inject lateinit var threadDb: ThreadDatabase + @Inject lateinit var viewModelFactory: DisappearingMessagesViewModel.AssistedFactory + + private val threadId: Long by lazy { + intent.getLongExtra(THREAD_ID, -1) + } + + private val viewModel: DisappearingMessagesViewModel by viewModels { + viewModelFactory.create(threadId) + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) + binding = ActivityDisappearingMessagesBinding.inflate(layoutInflater) + setContentView(binding.root) + + setUpToolbar() + + binding.container.setContent { DisappearingMessagesScreen() } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.event.collect { + when (it) { + Event.SUCCESS -> finish() + Event.FAIL -> showToast(getString(R.string.DisappearingMessagesActivity_settings_not_updated)) + } + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.state.collect { + supportActionBar?.subtitle = it.subtitle(this@DisappearingMessagesActivity) + } + } + } + } + + private fun showToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + private fun setUpToolbar() { + setSupportActionBar(binding.toolbar) + supportActionBar?.apply { + title = getString(R.string.activity_disappearing_messages_title) + setDisplayHomeAsUpEnabled(true) + setHomeButtonEnabled(true) + } + } + + companion object { + const val THREAD_ID = "thread_id" + } + + @Composable + fun DisappearingMessagesScreen() { + val uiState by viewModel.uiState.collectAsState(UiState()) + AppTheme { + DisappearingMessages(uiState, callbacks = viewModel) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt new file mode 100644 index 0000000000..32e20b73d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt @@ -0,0 +1,129 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.BuildConfig +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.ExpiryCallbacks +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.toUiState +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.ThreadDatabase + +class DisappearingMessagesViewModel( + private val threadId: Long, + private val application: Application, + private val textSecurePreferences: TextSecurePreferences, + private val messageExpirationManager: MessageExpirationManagerProtocol, + private val disappearingMessages: DisappearingMessages, + private val threadDb: ThreadDatabase, + private val groupDb: GroupDatabase, + private val storage: Storage, + isNewConfigEnabled: Boolean, + showDebugOptions: Boolean +) : AndroidViewModel(application), ExpiryCallbacks { + + private val _event = Channel<Event>() + val event = _event.receiveAsFlow() + + private val _state = MutableStateFlow( + State( + isNewConfigEnabled = isNewConfigEnabled, + showDebugOptions = showDebugOptions + ) + ) + val state = _state.asStateFlow() + + val uiState = _state + .map(State::toUiState) + .stateIn(viewModelScope, SharingStarted.Eagerly, UiState()) + + init { + viewModelScope.launch { + val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode?.maybeConvertToLegacy(isNewConfigEnabled) ?: ExpiryMode.NONE + val recipient = threadDb.getRecipientForThreadId(threadId) + val groupRecord = recipient?.takeIf { it.isClosedGroupRecipient } + ?.run { groupDb.getGroup(address.toGroupString()).orNull() } + + _state.update { + it.copy( + address = recipient?.address, + isGroup = groupRecord != null, + isNoteToSelf = recipient?.address?.serialize() == textSecurePreferences.getLocalNumber(), + isSelfAdmin = groupRecord == null || groupRecord.admins.any{ it.serialize() == textSecurePreferences.getLocalNumber() }, + expiryMode = expiryMode, + persistedMode = expiryMode + ) + } + } + } + + override fun setValue(value: ExpiryMode) = _state.update { it.copy(expiryMode = value) } + + override fun onSetClick() = viewModelScope.launch { + val state = _state.value + val mode = state.expiryMode?.coerceLegacyToAfterSend() + val address = state.address + if (address == null || mode == null) { + _event.send(Event.FAIL) + return@launch + } + + disappearingMessages.set(threadId, address, mode, state.isGroup) + + _event.send(Event.SUCCESS) + } + + private fun ExpiryMode.coerceLegacyToAfterSend() = takeUnless { it is ExpiryMode.Legacy } ?: ExpiryMode.AfterSend(expirySeconds) + + @dagger.assisted.AssistedFactory + interface AssistedFactory { + fun create(threadId: Long): Factory + } + + @Suppress("UNCHECKED_CAST") + class Factory @AssistedInject constructor( + @Assisted private val threadId: Long, + private val application: Application, + private val textSecurePreferences: TextSecurePreferences, + private val messageExpirationManager: MessageExpirationManagerProtocol, + private val disappearingMessages: DisappearingMessages, + private val threadDb: ThreadDatabase, + private val groupDb: GroupDatabase, + private val storage: Storage + ) : ViewModelProvider.Factory { + + override fun <T : ViewModel> create(modelClass: Class<T>): T = DisappearingMessagesViewModel( + threadId, + application, + textSecurePreferences, + messageExpirationManager, + disappearingMessages, + threadDb, + groupDb, + storage, + ExpirationConfiguration.isNewConfigEnabled, + BuildConfig.DEBUG + ) as T + } +} + +private fun ExpiryMode.maybeConvertToLegacy(isNewConfigEnabled: Boolean): ExpiryMode = takeIf { isNewConfigEnabled } ?: ExpiryMode.Legacy(expirySeconds) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/State.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/State.kt new file mode 100644 index 0000000000..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<ExpiryRadioOption>? = if (typeOptionsHidden) null else { + buildList { + add(offTypeOption()) + if (!isNewConfigEnabled) add(legacyTypeOption()) + if (!isGroup) add(afterReadTypeOption()) + add(afterSendTypeOption()) + } +} + +private fun State.timeOptions(): List<ExpiryRadioOption>? { + // Don't show times card if we have a types card, and type is off. + if (!typeOptionsHidden && expiryType == ExpiryType.NONE) return null + + return nextType.let { type -> + when (type) { + ExpiryType.AFTER_READ -> afterReadTimes + else -> afterSendTimes + }.map { timeOption(type, it) } + }.let { + buildList { + if (typeOptionsHidden) add(offTypeOption()) + addAll(debugOptions()) + addAll(it) + } + } +} + +private fun State.offTypeOption() = typeOption(ExpiryType.NONE) +private fun State.legacyTypeOption() = typeOption(ExpiryType.LEGACY) +private fun State.afterReadTypeOption() = newTypeOption(ExpiryType.AFTER_READ) +private fun State.afterSendTypeOption() = newTypeOption(ExpiryType.AFTER_SEND) +private fun State.newTypeOption(type: ExpiryType) = typeOption(type, isNewConfigEnabled && isSelfAdmin) + +private fun State.typeOption( + type: ExpiryType, + enabled: Boolean = isSelfAdmin, +) = ExpiryRadioOption( + value = type.defaultMode(persistedMode), + title = GetString(type.title), + subtitle = type.subtitle?.let(::GetString), + contentDescription = GetString(type.contentDescription), + selected = expiryType == type, + enabled = enabled +) + +private fun debugTimes(isDebug: Boolean) = if (isDebug) listOf(10.seconds, 30.seconds, 1.minutes) else emptyList() +private fun debugModes(isDebug: Boolean, type: ExpiryType) = + debugTimes(isDebug).map { type.mode(it.inWholeSeconds) } +private fun State.debugOptions(): List<ExpiryRadioOption> = + debugModes(showDebugOptions, nextType).map { timeOption(it, subtitle = GetString("for testing purposes")) } + +private val afterSendTimes = listOf(12.hours, 1.days, 7.days, 14.days) + +private val afterReadTimes = buildList { + add(5.minutes) + add(1.hours) + addAll(afterSendTimes) +} + +private fun State.timeOption( + type: ExpiryType, + time: Duration +) = timeOption(type.mode(time)) + +private fun State.timeOption( + mode: ExpiryMode, + title: GetString = GetString(mode.duration), + subtitle: GetString? = null, +) = ExpiryRadioOption( + value = mode, + title = title, + subtitle = subtitle, + contentDescription = title, + selected = mode.duration == expiryMode?.duration, + enabled = isTimeOptionsEnabled +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt new file mode 100644 index 0000000000..3fec60a0a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessages.kt @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.thoughtcrime.securesms.ui.Callbacks +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.NoOpCallbacks +import org.thoughtcrime.securesms.ui.OptionsCard +import org.thoughtcrime.securesms.ui.OutlineButton +import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.fadingEdges + +typealias ExpiryCallbacks = Callbacks<ExpiryMode> +typealias ExpiryRadioOption = RadioOption<ExpiryMode> + +@Composable +fun DisappearingMessages( + state: UiState, + modifier: Modifier = Modifier, + callbacks: ExpiryCallbacks = NoOpCallbacks +) { + val scrollState = rememberScrollState() + + Column(modifier = modifier.padding(horizontal = 32.dp)) { + Box(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier + .padding(bottom = 20.dp) + .verticalScroll(scrollState) + .fadingEdges(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + state.cards.forEach { + OptionsCard(it, callbacks) + } + + if (state.showGroupFooter) Text(text = stringResource(R.string.activity_disappearing_messages_group_footer), + style = TextStyle( + fontSize = 11.sp, + fontWeight = FontWeight(400), + color = Color(0xFFA1A2A1), + textAlign = TextAlign.Center), + modifier = Modifier.fillMaxWidth()) + } + } + + if (state.showSetButton) OutlineButton( + GetString(R.string.disappearing_messages_set_button_title), + modifier = Modifier + .contentDescription(GetString(R.string.AccessibilityId_set_button)) + .align(Alignment.CenterHorizontally) + .padding(bottom = 20.dp), + onClick = callbacks::onSetClick + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt new file mode 100644 index 0000000000..c2524bf261 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/DisappearingMessagesPreview.kt @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages.ui + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.conversation.disappearingmessages.State +import org.thoughtcrime.securesms.ui.PreviewTheme +import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider + +@Preview(widthDp = 450, heightDp = 700) +@Composable +fun PreviewStates( + @PreviewParameter(StatePreviewParameterProvider::class) state: State +) { + PreviewTheme(R.style.Classic_Dark) { + DisappearingMessages( + state.toUiState() + ) + } +} + +class StatePreviewParameterProvider : PreviewParameterProvider<State> { + override val values = newConfigValues.filter { it.expiryType != ExpiryType.LEGACY } + newConfigValues.map { it.copy(isNewConfigEnabled = false) } + + private val newConfigValues get() = sequenceOf( + // new 1-1 + State(expiryMode = ExpiryMode.NONE), + State(expiryMode = ExpiryMode.Legacy(43200)), + State(expiryMode = ExpiryMode.AfterRead(300)), + State(expiryMode = ExpiryMode.AfterSend(43200)), + // new group non-admin + State(isGroup = true, isSelfAdmin = false), + State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.Legacy(43200)), + State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.AfterSend(43200)), + // new group admin + State(isGroup = true), + State(isGroup = true, expiryMode = ExpiryMode.Legacy(43200)), + State(isGroup = true, expiryMode = ExpiryMode.AfterSend(43200)), + // new note-to-self + State(isNoteToSelf = true), + ) +} + +@Preview +@Composable +fun PreviewThemes( + @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int +) { + PreviewTheme(themeResId) { + DisappearingMessages( + State(expiryMode = ExpiryMode.AfterSend(43200)).toUiState(), + modifier = Modifier.size(400.dp, 600.dp) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt new file mode 100644 index 0000000000..40f917427c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/ui/UiState.kt @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages.ui + +import androidx.annotation.StringRes +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.RadioOption + +typealias ExpiryOptionsCard = OptionsCard<ExpiryMode> + +data class UiState( + val cards: List<ExpiryOptionsCard> = emptyList(), + val showGroupFooter: Boolean = false, + val showSetButton: Boolean = true +) { + constructor( + vararg cards: ExpiryOptionsCard, + showGroupFooter: Boolean = false, + showSetButton: Boolean = true, + ): this( + cards.asList(), + showGroupFooter, + showSetButton + ) +} + +data class OptionsCard<T>( + val title: GetString, + val options: List<RadioOption<T>> +) { + constructor(title: GetString, vararg options: RadioOption<T>): this(title, options.asList()) + constructor(@StringRes title: Int, vararg options: RadioOption<T>): this(GetString(title), options.asList()) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/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<Cursor>, + SearchBottomBar.EventListener, LoaderManager.LoaderCallbacks<Cursor>, 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<Cursor>) { @@ -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<Attachment>, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null): Pair<Address, Long>? { + private fun sendAttachments( + attachments: List<Attachment>, + body: String?, + quotedMessage: MessageRecord? = binding?.inputBar?.quote, + linkPreview: LinkPreview? = null + ): Pair<Address, Long>? { val recipient = viewModel.recipient ?: return null val sentTimestamp = SnodeAPI.nowWithOffset processMessageRequestApproval() // Create the message - val message = VisibleMessage() + val message = VisibleMessage().applyExpiryMode(viewModel.threadId) message.sentTimestamp = sentTimestamp message.text = body val quote = quotedMessage?.let { @@ -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<Boolean> { @@ -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<MessageRecord>) { - 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<MessageRecord>) { + 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<MessageRecord>) { + 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<MessageRecord>) { + 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<MessageRecord>) { 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<Contact>(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<VisibleMessageView>(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<MessageRecord>() val toDeselect = mutableSetOf<Pair<Int, MessageRecord>>() 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<String> emojis = recentEmojiPageModel.getEmoji(); - - for (int i = 0; i < emojiViews.length; i++) { - final EmojiImageView view = emojiViews[i]; - - view.setScaleX(1.0f); - view.setScaleY(1.0f); - view.setTranslationY(0); - - boolean isAtCustomIndex = i == customEmojiIndex; - - if (isAtCustomIndex) { - view.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_baseline_add_24)); - view.setTag(null); - } else { - view.setImageEmoji(emojis.get(i)); - } - } - } - - private int getSelectedIndexViaDownEvent(@NonNull MotionEvent motionEvent) { - return getSelectedIndexViaMotionEvent(motionEvent, new Boundary(emojiStripViewBounds.top, emojiStripViewBounds.bottom)); - } - - private int getSelectedIndexViaMoveEvent(@NonNull MotionEvent motionEvent) { - return getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary); - } - - private int getSelectedIndexViaMotionEvent(@NonNull MotionEvent motionEvent, @NonNull Boundary boundary) { - int selected = -1; - - if (backgroundView.getVisibility() != View.VISIBLE) { - return selected; - } - - for (int i = 0; i < emojiViews.length; i++) { - final float emojiLeft = (segmentSize * i) + emojiStripViewBounds.left; - horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize); - - if (horizontalEmojiBoundary.contains(motionEvent.getX()) && boundary.contains(motionEvent.getY())) { - selected = i; - } - } - - if (this.selected != -1 && this.selected != selected) { - shrinkView(emojiViews[this.selected]); - } - - if (this.selected != selected && selected != -1) { - growView(emojiViews[selected]); - } - - return selected; - } - - private void growView(@NonNull View view) { - view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); - view.animate() - .scaleY(1.5f) - .scaleX(1.5f) - .translationY(-selectedVerticalTranslation) - .setDuration(200) - .setInterpolator(INTERPOLATOR) - .start(); - } - - private void shrinkView(@NonNull View view) { - view.animate() - .scaleX(1.0f) - .scaleY(1.0f) - .translationY(0) - .setDuration(200) - .setInterpolator(INTERPOLATOR) - .start(); - } - - private void handleUpEvent() { - if (selected != -1 && onReactionSelectedListener != null && backgroundView.getVisibility() == View.VISIBLE) { - if (selected == customEmojiIndex) { - onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null); - } else { - onReactionSelectedListener.onReactionSelected(messageRecord, recentEmojiPageModel.getEmoji().get(selected)); - } - } else { - hide(); - } - } - - public void setOnReactionSelectedListener(@Nullable OnReactionSelectedListener onReactionSelectedListener) { - this.onReactionSelectedListener = onReactionSelectedListener; - } - - public void setOnActionSelectedListener(@Nullable OnActionSelectedListener onActionSelectedListener) { - this.onActionSelectedListener = onActionSelectedListener; - } - - public void setOnHideListener(@Nullable OnHideListener onHideListener) { - this.onHideListener = onHideListener; - } - - private @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) { - return Stream.of(messageRecord.getReactions()) - .filter(record -> record.getAuthor().equals(TextSecurePreferences.getLocalNumber(getContext()))) - .findFirst() - .map(ReactionRecord::getEmoji) - .orElse(null); - } - - private @NonNull List<ActionItem> getMenuActionItems(@NonNull MessageRecord message) { - List<ActionItem> items = new ArrayList<>(); - - // Prepare - boolean containsControlMessage = message.isUpdate(); - boolean hasText = !message.getBody().isEmpty(); - OpenGroup openGroup = DatabaseComponent.get(getContext()).lokiThreadDatabase().getOpenGroupChat(message.getThreadId()); - Recipient recipient = DatabaseComponent.get(getContext()).threadDatabase().getRecipientForThreadId(message.getThreadId()); - if (recipient == null) return Collections.emptyList(); - - String userPublicKey = TextSecurePreferences.getLocalNumber(getContext()); - // Select message - items.add(new ActionItem(R.attr.menu_select_icon, getContext().getResources().getString(R.string.conversation_context__menu_select), () -> handleActionItemClicked(Action.SELECT), - getContext().getResources().getString(R.string.AccessibilityId_select))); - // Reply - boolean canWrite = openGroup == null || openGroup.getCanWrite(); - if (canWrite && !message.isPending() && !message.isFailed()) { - items.add( - new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_reply), () -> handleActionItemClicked(Action.REPLY), - getContext().getResources().getString(R.string.AccessibilityId_reply_message)) - ); - } - // Copy message text - if (!containsControlMessage && hasText) { - items.add(new ActionItem(R.attr.menu_copy_icon, getContext().getResources().getString(R.string.copy), () -> handleActionItemClicked(Action.COPY_MESSAGE))); - } - // Copy Session ID - if (recipient.isGroupRecipient() && !recipient.isOpenGroupRecipient() && !message.getRecipient().getAddress().toString().equals(userPublicKey)) { - items.add(new ActionItem( - R.attr.menu_copy_icon, getContext().getResources().getString(R.string.activity_conversation_menu_copy_session_id), () -> handleActionItemClicked(Action.COPY_SESSION_ID)) - ); - } - // Delete message - if (ConversationMenuItemHelper.userCanDeleteSelectedItems(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) { - items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.delete), - () -> handleActionItemClicked(Action.DELETE), - getContext().getResources().getString(R.string.AccessibilityId_delete_message) - ) - ); - } - // Ban user - if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) { - items.add(new ActionItem(R.attr.menu_block_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_user), () -> handleActionItemClicked(Action.BAN_USER))); - } - // Ban and delete all - if (ConversationMenuItemHelper.userCanBanSelectedUsers(getContext(), message, openGroup, userPublicKey, blindedPublicKey)) { - items.add(new ActionItem(R.attr.menu_trash_icon, getContext().getResources().getString(R.string.conversation_context__menu_ban_and_delete_all), () -> handleActionItemClicked(Action.BAN_AND_DELETE_ALL))); - } - // Message detail - items.add(new ActionItem(R.attr.menu_info_icon, getContext().getResources().getString(R.string.conversation_context__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO))); - // Resend - if (message.isFailed()) { - items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resend_message), () -> handleActionItemClicked(Action.RESEND))); - } - // Resync - if (message.isSyncFailed()) { - items.add(new ActionItem(R.attr.menu_reply_icon, getContext().getResources().getString(R.string.conversation_context__menu_resync_message), () -> handleActionItemClicked(Action.RESYNC))); - } - // Save media - if (message.isMms() && ((MediaMmsMessageRecord)message).containsMediaSlide()) { - items.add(new ActionItem(R.attr.menu_save_icon, getContext().getResources().getString(R.string.conversation_context_image__save_attachment), () -> handleActionItemClicked(Action.DOWNLOAD), - getContext().getResources().getString(R.string.AccessibilityId_save_attachment)) - ); - } - - backgroundView.setVisibility(View.VISIBLE); - foregroundView.setVisibility(View.VISIBLE); - - return items; - } - - private void handleActionItemClicked(@NonNull Action action) { - hideInternal(new OnHideListener() { - @Override public void startHide() { - if (onHideListener != null) { - onHideListener.startHide(); - } - } - - @Override public void onHide() { - if (onHideListener != null) { - onHideListener.onHide(); - } - - if (onActionSelectedListener != null) { - onActionSelectedListener.onActionSelected(action); - } - } - }); - } - - private void initAnimators() { - - int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration); - int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset); - - List<Animator> reveals = Stream.of(emojiViews) - .mapIndexed((idx, v) -> { - Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal); - anim.setTarget(v); - anim.setStartDelay(idx * animationEmojiStartDelayFactor); - return anim; - }) - .toList(); - - Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in); - backgroundRevealAnim.setTarget(backgroundView); - backgroundRevealAnim.setDuration(revealDuration); - backgroundRevealAnim.setStartDelay(revealOffset); - reveals.add(backgroundRevealAnim); - - revealAnimatorSet.setInterpolator(INTERPOLATOR); - revealAnimatorSet.playTogether(reveals); - } - - private @NonNull AnimatorSet newHideAnimatorSet() { - AnimatorSet set = new AnimatorSet(); - - set.addListener(new AnimationCompleteListener() { - @Override - public void onAnimationEnd(Animator animation) { - setVisibility(View.GONE); - } - }); - set.setInterpolator(INTERPOLATOR); - - set.playTogether(newHideAnimators()); - - return set; - } - - private @NonNull List<Animator> newHideAnimators() { - int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration); - - List<Animator> animators = new ArrayList<>(Stream.of(emojiViews) - .mapIndexed((idx, v) -> { - Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide); - anim.setTarget(v); - return anim; - }) - .toList()); - - Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); - backgroundHideAnim.setTarget(backgroundView); - backgroundHideAnim.setDuration(duration); - animators.add(backgroundHideAnim); - - ObjectAnimator itemScaleXAnim = new ObjectAnimator(); - itemScaleXAnim.setProperty(View.SCALE_X); - itemScaleXAnim.setFloatValues(1f); - itemScaleXAnim.setTarget(conversationItem); - itemScaleXAnim.setDuration(duration); - animators.add(itemScaleXAnim); - - ObjectAnimator itemScaleYAnim = new ObjectAnimator(); - itemScaleYAnim.setProperty(View.SCALE_Y); - itemScaleYAnim.setFloatValues(1f); - itemScaleYAnim.setTarget(conversationItem); - itemScaleYAnim.setDuration(duration); - animators.add(itemScaleYAnim); - - ObjectAnimator itemXAnim = new ObjectAnimator(); - itemXAnim.setProperty(View.X); - itemXAnim.setFloatValues(selectedConversationModel.getBubbleX()); - itemXAnim.setTarget(conversationItem); - itemXAnim.setDuration(duration); - animators.add(itemXAnim); - - ObjectAnimator itemYAnim = new ObjectAnimator(); - itemYAnim.setProperty(View.Y); - itemYAnim.setFloatValues(selectedConversationModel.getBubbleY() - statusBarHeight); - itemYAnim.setTarget(conversationItem); - itemYAnim.setDuration(duration); - animators.add(itemYAnim); - - if (activity != null) { - ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor); - statusBarAnim.setDuration(duration); - statusBarAnim.addUpdateListener(animation -> { - WindowUtil.setStatusBarColor(activity.getWindow(), (int) animation.getAnimatedValue()); - }); - animators.add(statusBarAnim); - - ValueAnimator navigationBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalNavigationBarColor); - navigationBarAnim.setDuration(duration); - navigationBarAnim.addUpdateListener(animation -> { - WindowUtil.setNavigationBarColor(activity.getWindow(), (int) animation.getAnimatedValue()); - }); - animators.add(navigationBarAnim); - } - - return animators; - } - - public interface OnHideListener { - void startHide(); - void onHide(); - } - - public interface OnReactionSelectedListener { - void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji); - void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji); - } - - public interface OnActionSelectedListener { - void onActionSelected(@NonNull Action action); - } - - private static class Boundary { - private float min; - private float max; - - Boundary() {} - - Boundary(float min, float max) { - update(min, max); - } - - private void update(float min, float max) { - this.min = min; - this.max = max; - } - - public boolean contains(float value) { - if (min < max) { - return this.min < value && this.max > value; - } else { - return this.min > value && this.max < value; - } - } - } - - private enum OverlayState { - HIDDEN, - UNINITAILIZED, - DEADZONE, - SCRUB, - TAP - } - - public enum Action { - REPLY, - RESEND, - RESYNC, - DOWNLOAD, - COPY_MESSAGE, - COPY_SESSION_ID, - VIEW_INFO, - SELECT, - DELETE, - BAN_USER, - BAN_AND_DELETE_ALL, - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt new file mode 100644 index 0000000000..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<EmojiImageView> + private var contextMenu: ConversationContextMenu? = null + private var touchDownDeadZoneSize = 0f + private var distanceFromTouchDownPointToBottomOfScrubberDeadZone = 0f + private var scrubberWidth = 0 + private var selectedVerticalTranslation = 0 + private var scrubberHorizontalMargin = 0 + private var animationEmojiStartDelayFactor = 0 + private var statusBarHeight = 0 + private var onReactionSelectedListener: OnReactionSelectedListener? = null + private var onActionSelectedListener: OnActionSelectedListener? = null + private var onHideListener: OnHideListener? = null + private val revealAnimatorSet = AnimatorSet() + private var hideAnimatorSet = AnimatorSet() + + @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase + @Inject lateinit var repository: ConversationRepository + private val scope = CoroutineScope(Dispatchers.Default) + private var job: Job? = null + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + override fun onFinishInflate() { + super.onFinishInflate() + dropdownAnchor = findViewById(R.id.dropdown_anchor) + conversationItem = findViewById(R.id.conversation_item) + conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble) + conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp) + backgroundView = findViewById(R.id.conversation_reaction_scrubber_background) + foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground) + emojiViews = listOf(R.id.reaction_1, R.id.reaction_2, R.id.reaction_3, R.id.reaction_4, R.id.reaction_5, R.id.reaction_6, R.id.reaction_7).map { findViewById(it) } + customEmojiIndex = emojiViews.size - 1 + distanceFromTouchDownPointToBottomOfScrubberDeadZone = resources.getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom).toFloat() + touchDownDeadZoneSize = resources.getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size).toFloat() + scrubberWidth = resources.getDimensionPixelOffset(R.dimen.reaction_scrubber_width) + selectedVerticalTranslation = resources.getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation) + scrubberHorizontalMargin = resources.getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin) + animationEmojiStartDelayFactor = resources.getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor) + initAnimators() + } + + fun show(activity: Activity, + messageRecord: MessageRecord, + lastSeenDownPoint: PointF, + selectedConversationModel: SelectedConversationModel, + blindedPublicKey: String?) { + job?.cancel() + if (overlayState != OverlayState.HIDDEN) return + this.messageRecord = messageRecord + this.selectedConversationModel = selectedConversationModel + this.blindedPublicKey = blindedPublicKey + overlayState = OverlayState.UNINITAILIZED + selected = -1 + recentEmojiPageModel = RecentEmojiPageModel(activity) + setupSelectedEmoji() + val statusBarBackground = activity.findViewById<View>(android.R.id.statusBarBackground) + statusBarHeight = statusBarBackground?.height ?: 0 + val conversationItemSnapshot = selectedConversationModel.bitmap + conversationBubble.layoutParams = LinearLayout.LayoutParams(conversationItemSnapshot.width, conversationItemSnapshot.height) + conversationBubble.background = BitmapDrawable(resources, conversationItemSnapshot) + conversationTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), messageRecord.timestamp) + updateConversationTimestamp(messageRecord) + val isMessageOnLeft = selectedConversationModel.isOutgoing xor ViewUtil.isLtr(this) + conversationItem.scaleX = LONG_PRESS_SCALE_FACTOR + conversationItem.scaleY = LONG_PRESS_SCALE_FACTOR + visibility = INVISIBLE + this.activity = activity + updateSystemUiOnShow(activity) + doOnLayout { showAfterLayout(messageRecord, lastSeenDownPoint, isMessageOnLeft) } + + job = scope.launch(Dispatchers.IO) { + repository.changes(messageRecord.threadId) + .filter { mmsSmsDatabase.getMessageForTimestamp(messageRecord.timestamp) == null } + .collect { withContext(Dispatchers.Main) { hide() } } + } + } + + private fun updateConversationTimestamp(message: MessageRecord) { + if (message.isOutgoing) conversationBubble.bringToFront() else conversationTimestamp.bringToFront() + } + + private fun showAfterLayout(messageRecord: MessageRecord, + lastSeenDownPoint: PointF, + isMessageOnLeft: Boolean) { + val contextMenu = ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord)) + this.contextMenu = contextMenu + var endX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX - conversationItem.width + selectedConversationModel.bubbleWidth + var endY = selectedConversationModel.bubbleY - statusBarHeight + conversationItem.x = endX + conversationItem.y = endY + val conversationItemSnapshot = selectedConversationModel.bitmap + val isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < width + val overlayHeight = height + val bubbleWidth = selectedConversationModel.bubbleWidth + var endApparentTop = endY + var endScale = 1f + val menuPadding = DimensionUnit.DP.toPixels(12f) + val reactionBarTopPadding = DimensionUnit.DP.toPixels(32f) + val reactionBarHeight = backgroundView.height + var reactionBarBackgroundY: Float + if (isWideLayout) { + val everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.height < overlayHeight + if (everythingFitsVertically) { + val reactionBarFitsAboveItem = conversationItem.y > reactionBarHeight + menuPadding + reactionBarTopPadding + if (reactionBarFitsAboveItem) { + reactionBarBackgroundY = conversationItem.y - menuPadding - reactionBarHeight + } else { + endY = reactionBarHeight + menuPadding + reactionBarTopPadding + reactionBarBackgroundY = reactionBarTopPadding + } + } else { + val spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding + endScale = spaceAvailableForItem / conversationItem.height + endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1 + endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) + reactionBarBackgroundY = reactionBarTopPadding + } + } else { + val reactionBarOffset = DimensionUnit.DP.toPixels(48f) + val spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset, 0f) + val everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.height + menuPadding + spaceForReactionBar < overlayHeight + if (everythingFitsVertically) { + val bubbleBottom = selectedConversationModel.bubbleY + conversationItemSnapshot.height + val menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight + if (menuFitsBelowItem) { + if (conversationItem.y < 0) { + endY = 0f + } + val contextMenuTop = endY + conversationItemSnapshot.height + reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.bubbleY, contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY) + if (reactionBarBackgroundY <= reactionBarTopPadding) { + endY = backgroundView.height + menuPadding + reactionBarTopPadding + } + } else { + endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.height + reactionBarBackgroundY = endY - reactionBarHeight - menuPadding + } + endApparentTop = endY + } else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) { + val spaceAvailableForItem = overlayHeight.toFloat() - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar + endScale = spaceAvailableForItem / conversationItemSnapshot.height + endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1 + endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) + val contextMenuTop = endY + conversationItemSnapshot.height * endScale + reactionBarBackgroundY = reactionBarTopPadding //getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); + endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) + } else { + contextMenu.height = contextMenu.getMaxHeight() / 2 + val menuHeight = contextMenu.height + val fitsVertically = menuHeight + conversationItem.height + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight + if (fitsVertically) { + val bubbleBottom = selectedConversationModel.bubbleY + conversationItemSnapshot.height + val menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight + if (menuFitsBelowItem) { + reactionBarBackgroundY = conversationItem.y - menuPadding - reactionBarHeight + if (reactionBarBackgroundY < reactionBarTopPadding) { + endY = reactionBarTopPadding + reactionBarHeight + menuPadding + reactionBarBackgroundY = reactionBarTopPadding + } + } else { + endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.height + reactionBarBackgroundY = endY - reactionBarHeight - menuPadding + } + endApparentTop = endY + } else { + val spaceAvailableForItem = overlayHeight.toFloat() - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding + endScale = spaceAvailableForItem / conversationItemSnapshot.height + endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1 + endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) + menuPadding + reactionBarTopPadding + reactionBarBackgroundY = reactionBarTopPadding + endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding + } + } + } + reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight.toFloat()) + hideAnimatorSet.end() + visibility = VISIBLE + val scrubberX = if (isMessageOnLeft) { + scrubberHorizontalMargin.toFloat() + } else { + (width - scrubberWidth - scrubberHorizontalMargin).toFloat() + } + foregroundView.x = scrubberX + foregroundView.y = reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.height / 2f + backgroundView.x = scrubberX + backgroundView.y = reactionBarBackgroundY + verticalScrubBoundary.update(reactionBarBackgroundY, + lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone) + updateBoundsOnLayoutChanged() + revealAnimatorSet.start() + if (isWideLayout) { + val scrubberRight = scrubberX + scrubberWidth + val offsetX = if (isMessageOnLeft) scrubberRight + menuPadding else scrubberX - contextMenu.getMaxWidth() - menuPadding + contextMenu.show(offsetX.toInt(), Math.min(backgroundView.y, (overlayHeight - contextMenu.getMaxHeight()).toFloat()).toInt()) + } else { + val contentX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX + val offsetX = if (isMessageOnLeft) contentX else -contextMenu.getMaxWidth() + contentX + bubbleWidth + val menuTop = endApparentTop + conversationItemSnapshot.height * endScale + contextMenu.show(offsetX.toInt(), (menuTop + menuPadding).toInt()) + } + val revealDuration = context.resources.getInteger(R.integer.reaction_scrubber_reveal_duration) + conversationBubble.animate() + .scaleX(endScale) + .scaleY(endScale) + .setDuration(revealDuration.toLong()) + conversationItem.animate() + .x(endX) + .y(endY) + .setDuration(revealDuration.toLong()) + } + + private fun getReactionBarOffsetForTouch(itemY: Float, + contextMenuTop: Float, + contextMenuPadding: Float, + reactionBarOffset: Float, + reactionBarHeight: Int, + spaceNeededBetweenTopOfScreenAndTopOfReactionBar: Float, + messageTop: Float): Float { + val adjustedTouchY = itemY - statusBarHeight + var reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop) + val spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop) + if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150f)) { + val offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding + reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding + } + return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar) + } + + private fun updateSystemUiOnShow(activity: Activity) { + val window = activity.window + val barColor = ContextCompat.getColor(context, R.color.reactions_screen_dark_shade_color) + originalStatusBarColor = window.statusBarColor + WindowUtil.setStatusBarColor(window, barColor) + originalNavigationBarColor = window.navigationBarColor + WindowUtil.setNavigationBarColor(window, barColor) + if (!ThemeUtil.isDarkTheme(context)) { + WindowUtil.clearLightStatusBar(window) + WindowUtil.clearLightNavigationBar(window) + } + } + + fun hide() { + hideInternal(onHideListener) + } + + fun hideForReactWithAny() { + hideInternal(onHideListener) + } + + private fun hideInternal(onHideListener: OnHideListener?) { + job?.cancel() + overlayState = OverlayState.HIDDEN + val animatorSet = newHideAnimatorSet() + hideAnimatorSet = animatorSet + revealAnimatorSet.end() + animatorSet.start() + onHideListener?.startHide() + selectedConversationModel.focusedView?.let(ViewUtil::focusAndShowKeyboard) + animatorSet.addListener(object : AnimationCompleteListener() { + override fun onAnimationEnd(animation: Animator) { + animatorSet.removeListener(this) + onHideListener?.onHide() + } + }) + contextMenu?.dismiss() + } + + val isShowing: Boolean + get() = overlayState != OverlayState.HIDDEN + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + updateBoundsOnLayoutChanged() + } + + private fun updateBoundsOnLayoutChanged() { + backgroundView.getGlobalVisibleRect(emojiStripViewBounds) + emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect) + emojiStripViewBounds.left = getStart(emojiViewGlobalRect) + emojiViews[emojiViews.size - 1].getGlobalVisibleRect(emojiViewGlobalRect) + emojiStripViewBounds.right = getEnd(emojiViewGlobalRect) + segmentSize = emojiStripViewBounds.width() / emojiViews.size.toFloat() + } + + private fun getStart(rect: Rect): Int = if (ViewUtil.isLtr(this)) rect.left else rect.right + + private fun getEnd(rect: Rect): Int = if (ViewUtil.isLtr(this)) rect.right else rect.left + + fun applyTouchEvent(motionEvent: MotionEvent): Boolean { + check(isShowing) { "Touch events should only be propagated to this method if we are displaying the scrubber." } + if (motionEvent.action and MotionEvent.ACTION_POINTER_INDEX_MASK != 0) { + return true + } + if (overlayState == OverlayState.UNINITAILIZED) { + downIsOurs = false + deadzoneTouchPoint[motionEvent.x] = motionEvent.y + overlayState = OverlayState.DEADZONE + } + if (overlayState == OverlayState.DEADZONE) { + val deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.x) + val deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.y) + if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) { + overlayState = OverlayState.SCRUB + } else { + if (motionEvent.action == MotionEvent.ACTION_UP) { + overlayState = OverlayState.TAP + if (downIsOurs) { + handleUpEvent() + return true + } + } + return MotionEvent.ACTION_MOVE == motionEvent.action + } + } + return when (motionEvent.action) { + MotionEvent.ACTION_DOWN -> { + selected = getSelectedIndexViaDownEvent(motionEvent) + deadzoneTouchPoint[motionEvent.x] = motionEvent.y + overlayState = OverlayState.DEADZONE + downIsOurs = true + true + } + + MotionEvent.ACTION_MOVE -> { + selected = getSelectedIndexViaMoveEvent(motionEvent) + true + } + + MotionEvent.ACTION_UP -> { + handleUpEvent() + downIsOurs + } + + MotionEvent.ACTION_CANCEL -> { + hide() + downIsOurs + } + + else -> false + } + } + + private fun setupSelectedEmoji() { + val emojis = recentEmojiPageModel.emoji + emojiViews.forEachIndexed { i, view -> + view.scaleX = 1.0f + view.scaleY = 1.0f + view.translationY = 0f + val isAtCustomIndex = i == customEmojiIndex + if (isAtCustomIndex) { + view.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_baseline_add_24)) + view.tag = null + } else { + view.setImageEmoji(emojis[i]) + } + } + } + + private fun getSelectedIndexViaDownEvent(motionEvent: MotionEvent): Int = + getSelectedIndexViaMotionEvent(motionEvent, Boundary(emojiStripViewBounds.top.toFloat(), emojiStripViewBounds.bottom.toFloat())) + + private fun getSelectedIndexViaMoveEvent(motionEvent: MotionEvent): Int = + getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary) + + private fun getSelectedIndexViaMotionEvent(motionEvent: MotionEvent, boundary: Boundary): Int { + var selected = -1 + if (backgroundView.visibility != VISIBLE) { + return selected + } + for (i in emojiViews.indices) { + val emojiLeft = segmentSize * i + emojiStripViewBounds.left + horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize) + if (horizontalEmojiBoundary.contains(motionEvent.x) && boundary.contains(motionEvent.y)) { + selected = i + } + } + if (this.selected != -1 && this.selected != selected) { + shrinkView(emojiViews[this.selected]) + } + if (this.selected != selected && selected != -1) { + growView(emojiViews[selected]) + } + return selected + } + + private fun growView(view: View) { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + view.animate() + .scaleY(1.5f) + .scaleX(1.5f) + .translationY(-selectedVerticalTranslation.toFloat()) + .setDuration(200) + .setInterpolator(INTERPOLATOR) + .start() + } + + private fun shrinkView(view: View) { + view.animate() + .scaleX(1.0f) + .scaleY(1.0f) + .translationY(0f) + .setDuration(200) + .setInterpolator(INTERPOLATOR) + .start() + } + + private fun handleUpEvent() { + val onReactionSelectedListener = onReactionSelectedListener + if (selected != -1 && onReactionSelectedListener != null && backgroundView.visibility == VISIBLE) { + if (selected == customEmojiIndex) { + onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].tag != null) + } else { + onReactionSelectedListener.onReactionSelected(messageRecord, recentEmojiPageModel.emoji[selected]) + } + } else { + hide() + } + } + + fun setOnReactionSelectedListener(onReactionSelectedListener: OnReactionSelectedListener?) { + this.onReactionSelectedListener = onReactionSelectedListener + } + + fun setOnActionSelectedListener(onActionSelectedListener: OnActionSelectedListener?) { + this.onActionSelectedListener = onActionSelectedListener + } + + fun setOnHideListener(onHideListener: OnHideListener?) { + this.onHideListener = onHideListener + } + + private fun getOldEmoji(messageRecord: MessageRecord): String? = + messageRecord.reactions + .filter { it.author == getLocalNumber(context) } + .firstOrNull() + ?.let(ReactionRecord::emoji) + + private fun getMenuActionItems(message: MessageRecord): List<ActionItem> { + val items: MutableList<ActionItem> = ArrayList() + + // Prepare + val containsControlMessage = message.isUpdate + val hasText = !message.body.isEmpty() + val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(message.threadId) + val recipient = get(context).threadDatabase().getRecipientForThreadId(message.threadId) + ?: return emptyList() + val userPublicKey = getLocalNumber(context)!! + // Select message + items += ActionItem(R.attr.menu_select_icon, R.string.conversation_context__menu_select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select) + // Reply + val canWrite = openGroup == null || openGroup.canWrite + if (canWrite && !message.isPending && !message.isFailed && !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<Animator> { + val duration = context.resources.getInteger(R.integer.reaction_scrubber_hide_duration).toLong() + fun conversationItemAnimator(configure: ObjectAnimator.() -> Unit) = ObjectAnimator().apply { + target = conversationItem + setDuration(duration) + configure() + } + return emojiViews.map { + AnimatorInflaterCompat.loadAnimator(context, R.animator.reactions_scrubber_hide).apply { setTarget(it) } + } + AnimatorInflaterCompat.loadAnimator(context, android.R.animator.fade_out).apply { + setTarget(backgroundView) + setDuration(duration) + } + conversationItemAnimator { + setProperty(SCALE_X) + setFloatValues(1f) + } + conversationItemAnimator { + setProperty(SCALE_Y) + setFloatValues(1f) + } + conversationItemAnimator { + setProperty(X) + setFloatValues(selectedConversationModel.bubbleX) + } + conversationItemAnimator { + setProperty(Y) + setFloatValues(selectedConversationModel.bubbleY - statusBarHeight) + } + ValueAnimator.ofArgb(activity.window.statusBarColor, originalStatusBarColor).apply { + setDuration(duration) + addUpdateListener { animation: ValueAnimator -> WindowUtil.setStatusBarColor(activity.window, animation.animatedValue as Int) } + } + ValueAnimator.ofArgb(activity.window.statusBarColor, originalNavigationBarColor).apply { + setDuration(duration) + addUpdateListener { animation: ValueAnimator -> WindowUtil.setNavigationBarColor(activity.window, animation.animatedValue as Int) } + } + } + + interface OnHideListener { + fun startHide() + fun onHide() + } + + interface OnReactionSelectedListener { + fun onReactionSelected(messageRecord: MessageRecord, emoji: String) + fun onCustomReactionSelected(messageRecord: MessageRecord, hasAddedCustomEmoji: Boolean) + } + + interface OnActionSelectedListener { + fun onActionSelected(action: Action) + } + + private class Boundary(private var min: Float = 0f, private var max: Float = 0f) { + + fun update(min: Float, max: Float) { + this.min = min + this.max = max + } + + operator fun contains(value: Float) = if (min < max) { + min < value && max > value + } else { + min > value && max < value + } + } + + private enum class OverlayState { + HIDDEN, + UNINITAILIZED, + DEADZONE, + SCRUB, + TAP + } + + enum class Action { + REPLY, + RESEND, + RESYNC, + DOWNLOAD, + COPY_MESSAGE, + COPY_SESSION_ID, + VIEW_INFO, + SELECT, + DELETE, + BAN_USER, + BAN_AND_DELETE_ALL + } + + companion object { + const val LONG_PRESS_SCALE_FACTOR = 0.95f + private val INTERPOLATOR: Interpolator = DecelerateInterpolator() + } +} + +private fun Duration.to2partString(): String? = + toComponents { days, hours, minutes, seconds, nanoseconds -> listOf(days.days, hours.hours, minutes.minutes, seconds.seconds) } + .filter { it.inWholeSeconds > 0L }.take(2).takeIf { it.isNotEmpty() }?.joinToString(" ") + +private val MessageRecord.subtitle: ((Context) -> CharSequence?)? + get() = if (expiresIn <= 0) { + null + } else { context -> + (expiresIn - (SnodeAPI.nowWithOffset - (expireStarted.takeIf { it > 0 } ?: timestamp))) + .coerceAtLeast(0L) + .milliseconds + .to2partString() + ?.let { context.getString(R.string.auto_deletes_in, it) } + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 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<Recipient> = 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<OpenGroup> = 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 <T : ViewModel> create(modelClass: Class<T>): 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<Slide> = listOf() private var slideSize: Int = 0 - override fun dispatchDraw(canvas: Canvas?) { + override fun dispatchDraw(canvas: Canvas) { super.dispatchDraw(canvas) cornerMask.mask(canvas) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.java deleted file mode 100644 index 6765232c77..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.java +++ /dev/null @@ -1,129 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.components; - -import android.content.Context; -import android.util.AttributeSet; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.session.libsession.utilities.Util; - -import java.lang.ref.WeakReference; -import java.util.concurrent.TimeUnit; - -import network.loki.messenger.R; - -public class ExpirationTimerView extends androidx.appcompat.widget.AppCompatImageView { - - private long startedAt; - private long expiresIn; - - private boolean visible = false; - private boolean stopped = true; - - private final int[] frames = new int[]{ R.drawable.timer00, - R.drawable.timer05, - R.drawable.timer10, - R.drawable.timer15, - R.drawable.timer20, - R.drawable.timer25, - R.drawable.timer30, - R.drawable.timer35, - R.drawable.timer40, - R.drawable.timer45, - R.drawable.timer50, - R.drawable.timer55, - R.drawable.timer60 }; - - public ExpirationTimerView(Context context) { - super(context); - } - - public ExpirationTimerView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - public ExpirationTimerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public void setExpirationTime(long startedAt, long expiresIn) { - this.startedAt = startedAt; - this.expiresIn = expiresIn; - setPercentComplete(calculateProgress(this.startedAt, this.expiresIn)); - } - - public void setPercentComplete(float percentage) { - float percentFull = 1 - percentage; - int frame = (int) Math.ceil(percentFull * (frames.length - 1)); - - frame = Math.max(0, Math.min(frame, frames.length - 1)); - setImageResource(frames[frame]); - } - - public void startAnimation() { - synchronized (this) { - visible = true; - if (!stopped) return; - else stopped = false; - } - - Util.runOnMainDelayed(new AnimationUpdateRunnable(this), calculateAnimationDelay(this.startedAt, this.expiresIn)); - } - - public void stopAnimation() { - synchronized (this) { - visible = false; - } - } - - private float calculateProgress(long startedAt, long expiresIn) { - long progressed = System.currentTimeMillis() - startedAt; - float percentComplete = (float)progressed / (float)expiresIn; - - return Math.max(0, Math.min(percentComplete, 1)); - } - - private long calculateAnimationDelay(long startedAt, long expiresIn) { - long progressed = System.currentTimeMillis() - startedAt; - long remaining = expiresIn - progressed; - - if (remaining <= 0) { - return 0; - } else if (remaining < TimeUnit.SECONDS.toMillis(30)) { - return 1000; - } else { - return 5000; - } - } - - private static class AnimationUpdateRunnable implements Runnable { - - private final WeakReference<ExpirationTimerView> expirationTimerViewReference; - - private AnimationUpdateRunnable(@NonNull ExpirationTimerView expirationTimerView) { - this.expirationTimerViewReference = new WeakReference<>(expirationTimerView); - } - - @Override - public void run() { - ExpirationTimerView timerView = expirationTimerViewReference.get(); - if (timerView == null) return; - - long nextUpdate = timerView.calculateAnimationDelay(timerView.startedAt, timerView.expiresIn); - synchronized (timerView) { - if (timerView.visible) { - timerView.setExpirationTime(timerView.startedAt, timerView.expiresIn); - } else { - timerView.stopped = true; - return; - } - if (nextUpdate <= 0) { - timerView.stopped = true; - return; - } - } - Util.runOnMainDelayed(this, nextUpdate); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt new file mode 100644 index 0000000000..d173dacfef --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/ExpirationTimerView.kt @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.conversation.v2.components + +import android.content.Context +import android.graphics.drawable.AnimationDrawable +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.content.ContextCompat +import network.loki.messenger.R +import org.session.libsession.snode.SnodeAPI.nowWithOffset +import kotlin.math.round + +class ExpirationTimerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatImageView(context, attrs, defStyleAttr) { + private val frames = intArrayOf( + R.drawable.timer00, + R.drawable.timer05, + R.drawable.timer10, + R.drawable.timer15, + R.drawable.timer20, + R.drawable.timer25, + R.drawable.timer30, + R.drawable.timer35, + R.drawable.timer40, + R.drawable.timer45, + R.drawable.timer50, + R.drawable.timer55, + R.drawable.timer60 + ) + + fun setTimerIcon() { + setExpirationTime(0L, 0L) + } + + fun setExpirationTime(startedAt: Long, expiresIn: Long) { + if (expiresIn == 0L) { + setImageResource(R.drawable.timer55) + return + } + + if (startedAt == 0L) { + // timer has not started + setImageResource(R.drawable.timer60) + return + } + + val elapsedTime = nowWithOffset - startedAt + val remainingTime = expiresIn - elapsedTime + val remainingPercent = (remainingTime / expiresIn.toFloat()).coerceIn(0f, 1f) + + val frameCount = round(frames.size * remainingPercent).toInt().coerceIn(1, frames.size) + val frameTime = round(remainingTime / frameCount.toFloat()).toInt() + + AnimationDrawable().apply { + frames.take(frameCount).reversed().forEach { addFrame(ContextCompat.getDrawable(context, it)!!, frameTime) } + isOneShot = true + }.also(::setImageDrawable).apply(AnimationDrawable::start) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index 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<QuoteView>(R.id.mainQuoteViewContainer) - quoteView.delegate = this - binding.inputBarAdditionalContentContainer.addView(layout) - val attachments = (message as? MmsMessageRecord)?.slideDeck - val sender = if (message.isOutgoing) TextSecurePreferences.getLocalNumber(context)!! else message.individualRecipient.address.serialize() - quoteView.bind(sender, message.body, attachments, - thread, true, message.isOpenGroupInvitation, message.threadId, false, glide) + quoteView = layout.findViewById<QuoteView>(R.id.mainQuoteViewContainer).also { + it.delegate = this + binding.inputBarAdditionalContentContainer.addView(layout) + val attachments = (message as? MmsMessageRecord)?.slideDeck + val sender = if (message.isOutgoing) TextSecurePreferences.getLocalNumber(context)!! else message.individualRecipient.address.serialize() + it.bind(sender, message.body, attachments, thread, true, message.isOpenGroupInvitation, message.threadId, false, glide) + } + + // Before we request a layout update we'll add back any LinkPreviewDraftView that might + // exist - as this goes into the LinearLayout second it will be below the quote View. + if (linkPreview != null && linkPreviewDraftView != null) { + binding.inputBarAdditionalContentContainer.addView(linkPreviewDraftView) + } requestLayout() } override fun cancelQuoteDraft() { + binding.inputBarAdditionalContentContainer.removeView(quoteView) quote = null - binding.inputBarAdditionalContentContainer.removeAllViews() + quoteView = null requestLayout() } fun draftLinkPreview() { - quote = null - binding.inputBarAdditionalContentContainer.removeAllViews() - val linkPreviewDraftView = LinkPreviewDraftView(context) - linkPreviewDraftView.delegate = this - this.linkPreviewDraftView = linkPreviewDraftView + // As `draftLinkPreview` is called before `updateLinkPreview` when we modify a URI in a + // message we'll bail early if a link preview View already exists and just let + // `updateLinkPreview` get called to update the existing View. + if (linkPreview != null && linkPreviewDraftView != null) return + linkPreviewDraftView?.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<ImageView>(R.id.menu_badge_icon) - val badgeView = actionView.findViewById<TextView>(R.id.expiration_badge) - @ColorInt val color = context.getColorFromAttr(android.R.attr.textColorPrimary) - iconView.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.MULTIPLY) - badgeView.text = ExpirationUtil.getExpirationAbbreviatedDisplayValue(context, thread.expireMessages) - actionView.setOnClickListener { onOptionsItemSelected(item) } - } - } else { - inflater.inflate(R.menu.menu_conversation_expiration_off, menu) - } + if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) { + inflater.inflate(R.menu.menu_conversation_expiration, menu) } // One-on-one chat menu allows copying the session id if (thread.isContactRecipient) { @@ -110,7 +88,7 @@ object ConversationMenuHelper { inflater.inflate(R.menu.menu_conversation_notification_settings, menu) } - if (!thread.isGroupRecipient && thread.hasApprovedMe()) { + if (thread.showCallMenu()) { inflater.inflate(R.menu.menu_conversation_call, menu) } @@ -153,8 +131,7 @@ object ConversationMenuHelper { R.id.menu_view_all_media -> { showAllMedia(context, thread) } R.id.menu_search -> { search(context) } R.id.menu_add_shortcut -> { addShortcut(context, thread) } - R.id.menu_expiring_messages -> { showExpiringMessagesDialog(context, thread) } - R.id.menu_expiring_messages_off -> { showExpiringMessagesDialog(context, thread) } + R.id.menu_expiring_messages -> { showDisappearingMessages(context, thread) } R.id.menu_unblock -> { unblock(context, thread) } R.id.menu_block -> { block(context, thread, deleteThread = false) } R.id.menu_block_delete -> { blockAndDelete(context, thread) } @@ -210,6 +187,7 @@ object ConversationMenuHelper { private fun addShortcut(context: Context, thread: Recipient) { object : AsyncTask<Void?, Void?, IconCompat?>() { + @Deprecated("Deprecated in Java") override fun doInBackground(vararg params: Void?): IconCompat? { var icon: IconCompat? = null val contactPhoto = thread.contactPhoto @@ -228,6 +206,7 @@ object ConversationMenuHelper { return icon } + @Deprecated("Deprecated in Java") override fun onPostExecute(icon: IconCompat?) { val name = Optional.fromNullable<String>(thread.name) .or(Optional.fromNullable<String>(thread.profileName)) @@ -244,9 +223,9 @@ object ConversationMenuHelper { }.execute() } - private fun showExpiringMessagesDialog(context: Context, thread: Recipient) { + private fun showDisappearingMessages(context: Context, thread: Recipient) { val listener = context as? ConversationMenuListener ?: return - listener.showExpiringMessagesDialog(thread) + listener.showDisappearingMessages(thread) } private fun unblock(context: Context, thread: Recipient) { @@ -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<ConstraintLayout.LayoutParams> { + horizontalBias = if (message.isOutgoing) 1f else 0f + } onContentClick.add { event -> binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload) } @@ -233,9 +234,9 @@ class VisibleMessageContentView : ConstraintLayout { } } } - val layoutParams = binding.contentParent.layoutParams as ConstraintLayout.LayoutParams - layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f - binding.contentParent.layoutParams = layoutParams + binding.contentParent.modifyLayoutParams<ConstraintLayout.LayoutParams> { + horizontalBias = if (message.isOutgoing) 1f else 0f + } } private val onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() @@ -306,16 +307,9 @@ class VisibleMessageContentView : ConstraintLayout { } @ColorInt - fun getTextColor(context: Context, message: MessageRecord): Int { - val colorAttribute = if (message.isOutgoing) { - // sent - R.attr.message_sent_text_color - } else { - // received - R.attr.message_received_text_color - } - return context.getColorFromAttr(colorAttribute) - } + fun getTextColor(context: Context, message: MessageRecord): Int = context.getColorFromAttr( + if (message.isOutgoing) R.attr.message_sent_text_color else R.attr.message_received_text_color + ) } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 9538148fd0..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<WHATEVER>` 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<FrameLayout.LayoutParams> { + gravity = if (message.isOutgoing) Gravity.END else Gravity.START + } + binding.statusContainer.modifyLayoutParams<ConstraintLayout.LayoutParams> { + 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<WHATEVER>` 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<Long>() - 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<ExpirationDatabaseMetadata> = mutableListOf() + + readableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor -> + while (cursor.moveToNext()) { + configurations += readExpirationConfiguration(cursor) + } + } + + return configurations.firstOrNull() + } + + fun setExpirationConfiguration(configuration: ExpirationConfiguration) { + writableDatabase.beginTransaction() + try { + val values = ContentValues().apply { + put(THREAD_ID, configuration.threadId) + put(UPDATED_TIMESTAMP_MS, configuration.updatedTimestampMs) + } + + writableDatabase.insert(TABLE_NAME, null, values) + writableDatabase.setTransactionSuccessful() + notifyConversationListeners(configuration.threadId) + } finally { + writableDatabase.endTransaction() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt new file mode 100644 index 0000000000..40d38e8b70 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ExpirationInfo.kt @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.database + +data class ExpirationInfo( + val id: Long, + val timestamp: Long, + val expiresIn: Long, + val expireStarted: Long, + val isMms: Boolean +) { + private fun isDisappearAfterSend() = timestamp == expireStarted + fun isDisappearAfterRead() = expiresIn > 0 && !isDisappearAfterSend() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/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<VH extends RecyclerView.ViewHolder, T> - extends CursorRecyclerViewAdapter<VH> -{ - private static final String TAG = FastCursorRecyclerViewAdapter.class.getSimpleName(); - - private final LinkedList<T> fastRecords = new LinkedList<>(); - private final List<Long> 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<Long> releaseIdIterator = releasedRecordIds.iterator(); - - while (releaseIdIterator.hasNext()) { - long releasedId = releaseIdIterator.next(); - Iterator<T> 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<Long, Long>() + + @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<Long>) { + 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<Long>) { - val database = databaseHelper.writableDatabase - database.delete( - messageHashTable, - "${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})", + fun deleteMessageServerHashes(messageIDs: List<Long>, mms: Boolean) { + databaseHelper.writableDatabase.delete( + getMessageTable(mms), + "${Companion.messageID} IN (${messageIDs.joinToString(",") { "?" }})", messageIDs.map { "$it" }.toTypedArray() ) } - fun migrateThreadId(legacyThreadId: Long, newThreadId: Long) { - val database = databaseHelper.writableDatabase - val contentValues = ContentValues(1) - contentValues.put(threadID, newThreadId) - database.update(messageThreadMappingTable, contentValues, "$threadID = ?", arrayOf(legacyThreadId.toString())) - } + private fun getMessageTables(mms: Boolean) = sequenceOf( + getMessageTable(mms), + messageHashTable + ) + private fun getMessageTable(mms: Boolean) = if (mms) mmsHashTable else smsHashTable } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MarkedMessageInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MarkedMessageInfo.kt new file mode 100644 index 0000000000..9de3dac695 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MarkedMessageInfo.kt @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.database + +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId + +data class MarkedMessageInfo(val syncMessageId: SyncMessageId, val expirationInfo: ExpirationInfo) { + val expiryType get() = when { + syncMessageId.timetamp == expirationInfo.expireStarted -> ExpiryType.AFTER_SEND + expirationInfo.expiresIn > 0 -> ExpiryType.AFTER_READ + else -> ExpiryType.NONE + } + + val expiryMode get() = expiryType.mode(expirationInfo.expiresIn) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/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<InsertResult> { if (threadId < 0 ) throw MmsException("No thread ID supplied!") + if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, false.takeUnless { retrieved.groupId != null }) val contentValues = ContentValues() contentValues.put(DATE_SENT, retrieved.sentTimeMillis) contentValues.put(ADDRESS, retrieved.from.serialize()) @@ -570,7 +578,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa contentValues.put(PART_COUNT, retrieved.attachments.size) contentValues.put(SUBSCRIPTION_ID, retrieved.subscriptionId) contentValues.put(EXPIRES_IN, retrieved.expiresIn) - contentValues.put(READ, if (retrieved.isExpirationUpdate) 1 else 0) + contentValues.put(EXPIRE_STARTED, retrieved.expireStartedAt) contentValues.put(UNIDENTIFIED, retrieved.isUnidentified) contentValues.put(HAS_MENTION, retrieved.hasMention()) contentValues.put(MESSAGE_REQUEST_RESPONSE, retrieved.isMessageRequestResponse) @@ -619,8 +627,10 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa runThreadUpdate: Boolean ): Optional<InsertResult> { if (threadId < 0 ) throw MmsException("No thread ID supplied!") + if (retrieved.isExpirationUpdate) deleteExpirationTimerMessages(threadId, true.takeUnless { retrieved.isGroup }) val messageId = insertMessageOutbox(retrieved, threadId, false, null, serverTimestamp, runThreadUpdate) if (messageId == -1L) { + 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<String?>) { 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<String?>(ID), whereString, null, null, null, null) + cursor = db!!.query(TABLE_NAME, arrayOf<String?>(ID), whereString, null, null, null, null) val toDeleteStringMessageIds = mutableListOf<String>() 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<MessageRecord> getAllMessageRecordsFromSenderInThread(long threadId, String serializedAuthor) { + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\""; + Set<MessageRecord> identifiedMessages = new HashSet<MessageRecord>(); + + // 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<Long> getAllMessageIdsFromSenderInThread(long threadId, String serializedAuthor) { + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.ADDRESS + " = \"" + serializedAuthor + "\""; + + Set<Long> identifiedMessages = new HashSet<Long>(); + + // 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<String> 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<InsertResult> insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runThreadUpdate) { + Recipient recipient = Recipient.from(context, message.getSender(), true); + + Recipient groupRecipient; + + if (message.getGroupId() == null) { + groupRecipient = null; + } else { + groupRecipient = Recipient.from(context, message.getGroupId(), true); + } + + boolean unread = (Util.isDefaultSmsProvider(context) || + message.isSecureMessage() || message.isGroup() || message.isCallInfo()); + + long threadId; + + if (groupRecipient == null) threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient); + else threadId = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(groupRecipient); + if (message.isSecureMessage()) { type |= Types.SECURE_MESSAGE_BIT; } else if (message.isGroup()) { @@ -420,40 +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<InsertResult> 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<Long> threadIds) { + void deleteThread(long threadId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""}); + } + + void deleteThreads(Set<Long> 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<QuoteModel> = if (quotes != null) Optional.of(quotes) else Optional.absent() val linkPreviews: Optional<List<LinkPreview>> = if (linkPreview.isEmpty()) Optional.absent() else Optional.of(linkPreview.mapNotNull { it!! }) val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() val insertResult = if (isUserSender || isUserBlindedSender) { - val mediaMessage = OutgoingMediaMessage.from(message, targetRecipient, pointers, quote.orNull(), linkPreviews.orNull()?.firstOrNull()) + val mediaMessage = OutgoingMediaMessage.from( + message, + targetRecipient, + pointers, + quote.orNull(), + linkPreviews.orNull()?.firstOrNull(), + expiresInMillis, + expireStartedAt + ) mmsDatabase.insertSecureDecryptedMessageOutbox(mediaMessage, message.threadID ?: -1, message.sentTimestamp!!, runThreadUpdate) } else { // It seems like we have replaced SignalServiceAttachment with SessionServiceAttachment val signalServiceAttachments = attachments.mapNotNull { it.toSignalPointer() } - val mediaMessage = IncomingMediaMessage.from(message, senderAddress, targetRecipient.expireMessages * 1000L, group, signalServiceAttachments, quote, linkPreviews) + val mediaMessage = IncomingMediaMessage.from(message, senderAddress, expiresInMillis, expireStartedAt, group, signalServiceAttachments, quote, linkPreviews) mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID!!, message.receivedTimestamp ?: 0, runThreadUpdate) } if (insertResult.isPresent) { @@ -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<Long, Boolean>? { val database = DatabaseComponent.get(context).mmsSmsDatabase() val address = fromSerialized(author) - return database.getMessageFor(timestamp, address)?.getId() + return database.getMessageFor(timestamp, address)?.run { getId() to isMms } } override fun updateSentTimestamp( @@ -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<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) { + override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair, expirationTimer: Int) { val volatiles = configFactory.convoVolatile ?: return val userGroups = configFactory.userGroups ?: return + if (volatiles.getLegacyClosedGroup(groupPublicKey) != null && userGroups.getLegacyGroupInfo(groupPublicKey) != null) return val groupVolatileConfig = volatiles.getOrConstructLegacyGroup(groupPublicKey) groupVolatileConfig.lastRead = formationTimestamp volatiles.set(groupVolatileConfig) @@ -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<String>, admins: Collection<String>, sentTimestamp: Long) { val group = SignalServiceGroup(type, GroupUtil.getDecodedGroupIDAsData(groupID), SignalServiceGroup.GroupType.SIGNAL, name, members.toList(), null, admins.toList()) - val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, true, false) + val m = IncomingTextMessage(fromSerialized(senderPublicKey), 1, sentTimestamp, "", Optional.of(group), 0, 0, true, false) val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() val infoMessage = IncomingGroupMessage(m, groupID, updateData, true) val smsDB = DatabaseComponent.get(context).smsDatabase() @@ -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<String>, admins: Collection<String>, threadID: Long, sentTimestamp: Long) { - val userPublicKey = getUserPublicKey() + val userPublicKey = getUserPublicKey()!! val recipient = Recipient.from(context, fromSerialized(groupID), false) - val updateData = UpdateMessageData.buildGroupUpdate(type, name, members)?.toJSON() ?: "" - val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, true, null, listOf(), listOf()) + val infoMessage = OutgoingGroupMediaMessage(recipient, updateData, groupID, null, sentTimestamp, 0, 0, true, null, listOf(), listOf()) val mmsDB = DatabaseComponent.get(context).mmsDatabase() val mmsSmsDB = DatabaseComponent.get(context).mmsSmsDatabase() - if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return + 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<String>) { 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<LibSessionContact>) { + override fun addLibSessionContacts(contacts: List<LibSessionContact>, timestamp: Long) { val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() val moreContacts = contacts.filter { contact -> val id = SessionId(contact.id) @@ -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<Long>): List<Pair<Long, Long>> { + val expiringMessages = mutableListOf<Pair<Long, Long>>() + val smsDb = DatabaseComponent.get(context).smsDatabase() + smsDb.readerFor(smsDb.expirationNotStartedMessages).use { reader -> + while (reader.next != null) { + if (messageIds.isEmpty() || reader.current.id in messageIds) { + expiringMessages.add(reader.current.id to reader.current.expiresIn) + } + } + } + val mmsDb = DatabaseComponent.get(context).mmsDatabase() + mmsDb.expireNotStartedMessages.use { reader -> + while (reader.next != null) { + if (messageIds.isEmpty() || reader.current.id in messageIds) { + expiringMessages.add(reader.current.id to reader.current.expiresIn) + } + } + } + return expiringMessages + } + + override fun updateDisappearingState( + messageSender: String, + threadID: Long, + disappearingState: Recipient.DisappearingState + ) { + val threadDb = DatabaseComponent.get(context).threadDatabase() + val lokiDb = DatabaseComponent.get(context).lokiAPIDatabase() + val recipient = threadDb.getRecipientForThreadId(threadID) ?: return + val recipientAddress = recipient.address.serialize() + DatabaseComponent.get(context).recipientDatabase() + .setDisappearingState(recipient, disappearingState); + val currentLegacyRecipient = lokiDb.getLastLegacySenderAddress(recipientAddress) + val currentExpiry = getExpirationConfiguration(threadID) + if (disappearingState == DisappearingState.LEGACY + && currentExpiry?.isEnabled == true + && ExpirationConfiguration.isNewConfigEnabled) { // only set "this person is legacy" if new config enabled + lokiDb.setLastLegacySenderAddress(recipientAddress, messageSender) + } else if (messageSender == currentLegacyRecipient) { + lokiDb.setLastLegacySenderAddress(recipientAddress, null) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 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<String> 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<MarkedMessageInfo> messages = setRead(threadId, lastSeenTime); - if (isGroupRecipient) { - for (MarkedMessageInfo message: messages) { - MarkReadReceiver.scheduleDeletion(context, message.getExpirationInfo()); - } - } else { - MarkReadReceiver.process(context, messages); - } + MarkReadReceiver.process(context, messages); ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId); return setLastSeen(threadId, lastSeenTime); } - 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<RecipientSettings> 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<ReactionRecord> 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<PlaceholderAvatarPhoto, BitmapDrawable> { +class PlaceholderAvatarLoader(private val appContext: Context): ModelLoader<PlaceholderAvatarPhoto, BitmapDrawable> { override fun buildLoadData( model: PlaceholderAvatarPhoto, @@ -16,14 +17,14 @@ class PlaceholderAvatarLoader(): ModelLoader<PlaceholderAvatarPhoto, BitmapDrawa height: Int, options: Options ): LoadData<BitmapDrawable> { - return LoadData(model, PlaceholderAvatarFetcher(model.context, model)) + return LoadData(model, PlaceholderAvatarFetcher(appContext, model)) } override fun handles(model: PlaceholderAvatarPhoto): Boolean = true - class Factory() : ModelLoaderFactory<PlaceholderAvatarPhoto, BitmapDrawable> { + class Factory(private val appContext: Context) : ModelLoaderFactory<PlaceholderAvatarPhoto, BitmapDrawable> { override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<PlaceholderAvatarPhoto, BitmapDrawable> { - 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<String>, val zombieMembers: List<String>) 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<GlobalSearchViewModel>() private val homeViewModel by viewModels<HomeViewModel>() @@ -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<RecyclerView.ViewHolder>(), 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<ThreadRecord> = emptyList() - var data: List<ThreadRecord> - 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<Long>() - 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<ThreadRecord>, - private val new: List<ThreadRecord>, - 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<Unit>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) - private val executor = viewModelScope + SupervisorJob() - private var lastContext: WeakReference<Context>? = null - private var updateJobs: MutableList<Job> = 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<Data?> = combine( + observeConversationList(), + observeTypingStatus(), + messageRequests(), + ::Data + ) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) - private val _conversations = MutableLiveData<List<ThreadRecord>>() - val conversations: LiveData<List<ThreadRecord>> = _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<Unit>(capacity = Channel.CONFLATED) + private fun observeTypingStatus(): Flow<Set<Long>> = + 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<List<ThreadRecord>> { - // 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<ThreadRecord>() - 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<List<ThreadRecord>> = 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<ThreadRecord> = emptyList(), + val typingThreadIDs: Set<Long> = 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<InputFilter>(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<GlobalSearchResult> = - MutableStateFlow(GlobalSearchResult.EMPTY) + private val _result: MutableStateFlow<GlobalSearchResult> = MutableStateFlow(GlobalSearchResult.EMPTY) val result: StateFlow<GlobalSearchResult> = _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<SearchResult>() 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<Attachment> 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<Optional<LongMessage>> callback) { - SignalExecutors.BOUNDED.execute(() -> { - if (isMms) { - callback.onComplete(getMmsLongMessage(context, mmsDatabase, messageId)); - } else { - callback.onComplete(getSmsLongMessage(smsDatabase, messageId)); - } - }); - } - - @WorkerThread - private Optional<LongMessage> getMmsLongMessage(@NonNull Context context, @NonNull MmsDatabase mmsDatabase, long messageId) { - Optional<MmsMessageRecord> record = getMmsMessage(mmsDatabase, messageId); - - if (record.isPresent()) { - TextSlide textSlide = record.get().getSlideDeck().getTextSlide(); - - if (textSlide != null && textSlide.getUri() != null) { - return Optional.of(new LongMessage(record.get(), readFullBody(context, textSlide.getUri()))); - } else { - return Optional.of(new LongMessage(record.get(), "")); - } - } else { - return Optional.absent(); - } - } - - @WorkerThread - private Optional<LongMessage> getSmsLongMessage(@NonNull SmsDatabase smsDatabase, long messageId) { - Optional<MessageRecord> record = getSmsMessage(smsDatabase, messageId); - - if (record.isPresent()) { - return Optional.of(new LongMessage(record.get(), "")); - } else { - return Optional.absent(); - } - } - - - @WorkerThread - private Optional<MmsMessageRecord> getMmsMessage(@NonNull MmsDatabase mmsDatabase, long messageId) { - try (Cursor cursor = mmsDatabase.getMessage(messageId)) { - return Optional.fromNullable((MmsMessageRecord) mmsDatabase.readerFor(cursor).getNext()); - } - } - - @WorkerThread - private Optional<MessageRecord> getSmsMessage(@NonNull SmsDatabase smsDatabase, long messageId) { - try (Cursor cursor = smsDatabase.getMessageCursor(messageId)) { - return Optional.fromNullable(smsDatabase.readerFor(cursor).getNext()); - } - } - - private String readFullBody(@NonNull Context context, @NonNull Uri uri) { - try (InputStream stream = PartAuthority.getAttachmentStream(context, uri)) { - return Util.readFullyAsString(stream); - } catch (IOException e) { - Log.w(TAG, "Failed to read full text body.", e); - return ""; - } - } - - interface Callback<T> { - void onComplete(T result); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageViewModel.java deleted file mode 100644 index 27495a492a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageViewModel.java +++ /dev/null @@ -1,84 +0,0 @@ -package org.thoughtcrime.securesms.longmessage; - -import android.app.Application; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; - -import android.database.ContentObserver; -import android.net.Uri; -import android.os.Handler; -import androidx.annotation.NonNull; - -import org.thoughtcrime.securesms.database.DatabaseContentProviders; -import org.session.libsignal.utilities.guava.Optional; - -class LongMessageViewModel extends ViewModel { - - private final Application application; - private final LongMessageRepository repository; - private final long messageId; - private final boolean isMms; - - private final MutableLiveData<Optional<LongMessage>> message; - private final MessageObserver messageObserver; - - private LongMessageViewModel(@NonNull Application application, @NonNull LongMessageRepository repository, long messageId, boolean isMms) { - this.application = application; - this.repository = repository; - this.messageId = messageId; - this.isMms = isMms; - this.message = new MutableLiveData<>(); - this.messageObserver = new MessageObserver(new Handler()); - - repository.getMessage(application, messageId, isMms, longMessage -> { - if (longMessage.isPresent()) { - Uri uri = DatabaseContentProviders.Conversation.getUriForThread(longMessage.get().getMessageRecord().getThreadId()); - application.getContentResolver().registerContentObserver(uri, true, messageObserver); - } - - message.postValue(longMessage); - }); - } - - LiveData<Optional<LongMessage>> getMessage() { - return message; - } - - @Override - protected void onCleared() { - application.getContentResolver().unregisterContentObserver(messageObserver); - } - - private class MessageObserver extends ContentObserver { - MessageObserver(Handler handler) { - super(handler); - } - - @Override - public void onChange(boolean selfChange) { - repository.getMessage(application, messageId, isMms, message::postValue); - } - } - - static class Factory extends ViewModelProvider.NewInstanceFactory { - - private final Application context; - private final LongMessageRepository repository; - private final long messageId; - private final boolean isMms; - - public Factory(@NonNull Application application, @NonNull LongMessageRepository repository, long messageId, boolean isMms) { - this.context = application; - this.repository = repository; - this.messageId = messageId; - this.isMms = isMms; - } - - @Override - public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) { - return modelClass.cast(new LongMessageViewModel(context, repository, messageId, isMms)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/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<InstanceIdResult>)->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<Map<*, *>, Exception> { - return OnionRequestAPI.sendOnionRequest(request, server, pnServerPublicKey, Version.V2).map { response -> - JsonUtil.fromJson(response.body, Map::class.java) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java deleted file mode 100644 index 309f2732f8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java +++ /dev/null @@ -1,100 +0,0 @@ -package org.thoughtcrime.securesms.notifications; - -import android.annotation.SuppressLint; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.AsyncTask; - -import androidx.annotation.NonNull; -import androidx.core.app.NotificationManagerCompat; - -import com.annimon.stream.Collectors; -import com.annimon.stream.Stream; - -import org.session.libsession.database.StorageProtocol; -import org.session.libsession.messaging.MessagingModuleConfiguration; -import org.session.libsession.messaging.messages.control.ReadReceipt; -import org.session.libsession.messaging.sending_receiving.MessageSender; -import org.session.libsession.snode.SnodeAPI; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.database.MessagingDatabase.ExpirationInfo; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; -import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.service.ExpiringMessageManager; -import org.thoughtcrime.securesms.util.SessionMetaProtocol; - -import java.util.List; -import java.util.Map; - -public class MarkReadReceiver extends BroadcastReceiver { - - private static final String TAG = MarkReadReceiver.class.getSimpleName(); - public static final String CLEAR_ACTION = "network.loki.securesms.notifications.CLEAR"; - public static final String THREAD_IDS_EXTRA = "thread_ids"; - public static final String NOTIFICATION_ID_EXTRA = "notification_id"; - - @SuppressLint("StaticFieldLeak") - @Override - public void onReceive(final Context context, Intent intent) { - if (!CLEAR_ACTION.equals(intent.getAction())) - return; - - final long[] threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA); - - if (threadIds != null) { - NotificationManagerCompat.from(context).cancel(intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)); - - new AsyncTask<Void, Void, Void>() { - @Override - protected Void doInBackground(Void... params) { - long currentTime = SnodeAPI.getNowWithOffset(); - for (long threadId : threadIds) { - Log.i(TAG, "Marking as read: " + threadId); - StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage(); - storage.markConversationAsRead(threadId,currentTime, true); - } - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - } - - public static void process(@NonNull Context context, @NonNull List<MarkedMessageInfo> markedReadMessages) { - if (markedReadMessages.isEmpty()) return; - - for (MarkedMessageInfo messageInfo : markedReadMessages) { - scheduleDeletion(context, messageInfo.getExpirationInfo()); - } - - if (!TextSecurePreferences.isReadReceiptsEnabled(context)) return; - - Map<Address, List<SyncMessageId>> addressMap = Stream.of(markedReadMessages) - .map(MarkedMessageInfo::getSyncMessageId) - .collect(Collectors.groupingBy(SyncMessageId::getAddress)); - - for (Address address : addressMap.keySet()) { - List<Long> timestamps = Stream.of(addressMap.get(address)).map(SyncMessageId::getTimetamp).toList(); - if (!SessionMetaProtocol.shouldSendReadReceipt(Recipient.from(context, address, false))) { continue; } - ReadReceipt readReceipt = new ReadReceipt(timestamps); - readReceipt.setSentTimestamp(SnodeAPI.getNowWithOffset()); - MessageSender.send(readReceipt, address); - } - } - - public static void scheduleDeletion(Context context, ExpirationInfo expirationInfo) { - if (expirationInfo.getExpiresIn() > 0 && expirationInfo.getExpireStarted() <= 0) { - ExpiringMessageManager expirationManager = ApplicationContext.getInstance(context).getExpiringMessageManager(); - - if (expirationInfo.isMms()) DatabaseComponent.get(context).mmsDatabase().markExpireStarted(expirationInfo.getId()); - else DatabaseComponent.get(context).smsDatabase().markExpireStarted(expirationInfo.getId()); - - expirationManager.scheduleDeletion(expirationInfo.getId(), expirationInfo.isMms(), expirationInfo.getExpiresIn()); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt new file mode 100644 index 0000000000..59681c1f8a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -0,0 +1,161 @@ +package org.thoughtcrime.securesms.notifications + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.AsyncTask +import androidx.core.app.NotificationManagerCompat +import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.sending_receiving.MessageSender.send +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled +import org.session.libsession.utilities.associateByNotNull +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.database.ExpirationInfo +import org.thoughtcrime.securesms.database.MarkedMessageInfo +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.util.SessionMetaProtocol.shouldSendReadReceipt + +class MarkReadReceiver : BroadcastReceiver() { + @SuppressLint("StaticFieldLeak") + override fun onReceive(context: Context, intent: Intent) { + if (CLEAR_ACTION != intent.action) return + val threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA) ?: return + NotificationManagerCompat.from(context).cancel(intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)) + object : AsyncTask<Void?, Void?, Void?>() { + override fun doInBackground(vararg params: Void?): Void? { + val currentTime = nowWithOffset + threadIds.forEach { + Log.i(TAG, "Marking as read: $it") + shared.storage.markConversationAsRead(it, currentTime, true) + } + return null + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } + + companion object { + private val TAG = MarkReadReceiver::class.java.simpleName + const val CLEAR_ACTION = "network.loki.securesms.notifications.CLEAR" + const val THREAD_IDS_EXTRA = "thread_ids" + const val NOTIFICATION_ID_EXTRA = "notification_id" + + val messageExpirationManager = SSKEnvironment.shared.messageExpirationManager + + @JvmStatic + fun process( + context: Context, + markedReadMessages: List<MarkedMessageInfo> + ) { + if (markedReadMessages.isEmpty()) return + + sendReadReceipts(context, markedReadMessages) + + val mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase() + + val threadDb = DatabaseComponent.get(context).threadDatabase() + + // start disappear after read messages except TimerUpdates in groups. + markedReadMessages + .filter { it.expiryType == ExpiryType.AFTER_READ } + .map { it.syncMessageId } + .filter { mmsSmsDatabase.getMessageForTimestamp(it.timetamp)?.run { + isExpirationTimerUpdate && threadDb.getRecipientForThreadId(threadId)?.isGroupRecipient == true } == false + } + .forEach { messageExpirationManager.startDisappearAfterRead(it.timetamp, it.address.serialize()) } + + hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { + fetchUpdatedExpiriesAndScheduleDeletion(context, it) + shortenExpiryOfDisappearingAfterRead(context, it) + } + } + + private fun hashToDisappearAfterReadMessage( + context: Context, + markedReadMessages: List<MarkedMessageInfo> + ): Map<String, MarkedMessageInfo>? { + val loki = DatabaseComponent.get(context).lokiMessageDatabase() + + return markedReadMessages + .filter { it.expiryType == ExpiryType.AFTER_READ } + .associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id, isMms) } } + .takeIf { it.isNotEmpty() } + } + + private fun shortenExpiryOfDisappearingAfterRead( + context: Context, + hashToMessage: Map<String, MarkedMessageInfo> + ) { + hashToMessage.entries + .groupBy( + keySelector = { it.value.expirationInfo.expiresIn }, + valueTransform = { it.key } + ).forEach { (expiresIn, hashes) -> + SnodeAPI.alterTtl( + messageHashes = hashes, + newExpiry = nowWithOffset + expiresIn, + publicKey = TextSecurePreferences.getLocalNumber(context)!!, + shorten = true + ) + } + } + + private fun sendReadReceipts( + context: Context, + markedReadMessages: List<MarkedMessageInfo> + ) { + if (!isReadReceiptsEnabled(context)) return + + markedReadMessages.map { it.syncMessageId } + .filter { shouldSendReadReceipt(Recipient.from(context, it.address, false)) } + .groupBy { it.address } + .forEach { (address, messages) -> + messages.map { it.timetamp } + .let(::ReadReceipt) + .apply { sentTimestamp = nowWithOffset } + .let { send(it, address) } + } + } + + private fun fetchUpdatedExpiriesAndScheduleDeletion( + context: Context, + hashToMessage: Map<String, MarkedMessageInfo> + ) { + @Suppress("UNCHECKED_CAST") + val expiries = SnodeAPI.getExpiries(hashToMessage.keys.toList(), TextSecurePreferences.getLocalNumber(context)!!).get()["expiries"] as Map<String, Long> + hashToMessage.forEach { (hash, info) -> expiries[hash]?.let { scheduleDeletion(context, info.expirationInfo, it - info.expirationInfo.expireStarted) } } + } + + private fun scheduleDeletion( + context: Context?, + expirationInfo: ExpirationInfo, + expiresIn: Long = expirationInfo.expiresIn + ) { + if (expiresIn == 0L) return + + val now = nowWithOffset + + val expireStarted = expirationInfo.expireStarted + + if (expirationInfo.isDisappearAfterRead() && expireStarted == 0L || now < expireStarted) { + val db = DatabaseComponent.get(context!!).run { if (expirationInfo.isMms) mmsDatabase() else smsDatabase() } + db.markExpireStarted(expirationInfo.id, now) + } + + ApplicationContext.getInstance(context).expiringMessageManager.scheduleDeletion( + expirationInfo.id, + expirationInfo.isMms, + now, + expiresIn + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java index a4871f0fc9..cad3b6f6c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java @@ -53,7 +53,7 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu public void setMostRecentSender(Recipient recipient, Recipient threadRecipient) { String displayName = recipient.toShortString(); if (threadRecipient.isGroupRecipient()) { - displayName = getGroupDisplayName(recipient, threadRecipient.isOpenGroupRecipient()); + displayName = getGroupDisplayName(recipient, threadRecipient.isCommunityRecipient()); } if (privacy.isDisplayContact()) { setContentText(context.getString(R.string.MessageNotifier_most_recent_from_s, displayName)); @@ -79,7 +79,7 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu public void addMessageBody(@NonNull Recipient sender, Recipient threadRecipient, @Nullable CharSequence body) { String displayName = sender.toShortString(); if (threadRecipient.isGroupRecipient()) { - displayName = getGroupDisplayName(sender, threadRecipient.isOpenGroupRecipient()); + displayName = getGroupDisplayName(sender, threadRecipient.isCommunityRecipient()); } if (privacy.isDisplayMessage()) { SpannableStringBuilder builder = new SpannableStringBuilder(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushManager.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushManager.kt new file mode 100644 index 0000000000..d094644c07 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushManager.kt @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.notifications + +interface PushManager { + fun refresh(force: Boolean) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushNotificationService.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushNotificationService.kt deleted file mode 100644 index fc399d293e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushNotificationService.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.thoughtcrime.securesms.notifications - -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import com.google.firebase.messaging.FirebaseMessagingService -import com.google.firebase.messaging.RemoteMessage -import org.session.libsession.messaging.jobs.BatchMessageReceiveJob -import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.messaging.utilities.MessageWrapper -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.Log - -class PushNotificationService : FirebaseMessagingService() { - - override fun onNewToken(token: String) { - super.onNewToken(token) - Log.d("Loki", "New FCM token: $token.") - val userPublicKey = TextSecurePreferences.getLocalNumber(this) ?: return - LokiPushNotificationManager.register(token, userPublicKey, this, false) - } - - override fun onMessageReceived(message: RemoteMessage) { - Log.d("Loki", "Received a push notification.") - val base64EncodedData = message.data?.get("ENCRYPTED_DATA") - val data = base64EncodedData?.let { Base64.decode(it) } - if (data != null) { - try { - val envelopeAsData = MessageWrapper.unwrap(data).toByteArray() - val job = BatchMessageReceiveJob(listOf(MessageReceiveParameters(envelopeAsData)), null) - JobQueue.shared.add(job) - } catch (e: Exception) { - Log.d("Loki", "Failed to unwrap data for message due to error: $e.") - } - } else { - Log.d("Loki", "Failed to decode data for message.") - val builder = NotificationCompat.Builder(this, NotificationChannels.OTHER) - .setSmallIcon(network.loki.messenger.R.drawable.ic_notification) - .setColor(this.getResources().getColor(network.loki.messenger.R.color.textsecure_primary)) - .setContentTitle("Session") - .setContentText("You've got a new message.") - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setAutoCancel(true) - with(NotificationManagerCompat.from(this)) { - notify(11111, builder.build()) - } - } - } - - override fun onDeletedMessages() { - Log.d("Loki", "Called onDeletedMessages.") - super.onDeletedMessages() - val token = TextSecurePreferences.getFCMToken(this)!! - val userPublicKey = TextSecurePreferences.getLocalNumber(this) ?: return - LokiPushNotificationManager.register(token, userPublicKey, this, true) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt new file mode 100644 index 0000000000..5f218a7a9f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt @@ -0,0 +1,116 @@ +package org.thoughtcrime.securesms.notifications + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.goterl.lazysodium.LazySodiumAndroid +import com.goterl.lazysodium.SodiumAndroid +import com.goterl.lazysodium.interfaces.AEAD +import com.goterl.lazysodium.utils.Key +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonBuilder +import org.session.libsession.messaging.jobs.BatchMessageReceiveJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.jobs.MessageReceiveParameters +import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata +import org.session.libsession.messaging.utilities.MessageWrapper +import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.messaging.utilities.SodiumUtilities.sodium +import org.session.libsession.utilities.bencode.Bencode +import org.session.libsession.utilities.bencode.BencodeList +import org.session.libsession.utilities.bencode.BencodeString +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import javax.inject.Inject + +private const val TAG = "PushHandler" + +class PushReceiver @Inject constructor(@ApplicationContext val context: Context) { + private val json = Json { ignoreUnknownKeys = true } + + fun onPush(dataMap: Map<String, String>?) { + onPush(dataMap?.asByteArray()) + } + + fun onPush(data: ByteArray?) { + if (data == null) { + onPush() + return + } + + try { + val envelopeAsData = MessageWrapper.unwrap(data).toByteArray() + val job = BatchMessageReceiveJob(listOf(MessageReceiveParameters(envelopeAsData)), null) + JobQueue.shared.add(job) + } catch (e: Exception) { + Log.d(TAG, "Failed to unwrap data for message due to error.", e) + } + } + + private fun onPush() { + Log.d(TAG, "Failed to decode data for message.") + val builder = NotificationCompat.Builder(context, NotificationChannels.OTHER) + .setSmallIcon(network.loki.messenger.R.drawable.ic_notification) + .setColor(context.getColor(network.loki.messenger.R.color.textsecure_primary)) + .setContentTitle("Session") + .setContentText("You've got a new message.") + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + NotificationManagerCompat.from(context).notify(11111, builder.build()) + } + + private fun Map<String, String>.asByteArray() = + when { + // this is a v2 push notification + containsKey("spns") -> { + try { + decrypt(Base64.decode(this["enc_payload"])) + } catch (e: Exception) { + Log.e(TAG, "Invalid push notification", e) + null + } + } + // old v1 push notification; we still need this for receiving legacy closed group notifications + else -> this["ENCRYPTED_DATA"]?.let(Base64::decode) + } + + private fun decrypt(encPayload: ByteArray): ByteArray? { + Log.d(TAG, "decrypt() called") + + val encKey = getOrCreateNotificationKey() + val nonce = encPayload.take(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray() + val payload = encPayload.drop(AEAD.XCHACHA20POLY1305_IETF_NPUBBYTES).toByteArray() + val padded = SodiumUtilities.decrypt(payload, encKey.asBytes, nonce) + ?: error("Failed to decrypt push notification") + val decrypted = padded.dropLastWhile { it.toInt() == 0 }.toByteArray() + val bencoded = Bencode.Decoder(decrypted) + val expectedList = (bencoded.decode() as? BencodeList)?.values + ?: error("Failed to decode bencoded list from payload") + + val metadataJson = (expectedList[0] as? BencodeString)?.value ?: error("no metadata") + val metadata: PushNotificationMetadata = json.decodeFromString(String(metadataJson)) + + return (expectedList.getOrNull(1) as? BencodeString)?.value.also { + // null content is valid only if we got a "data_too_long" flag + it?.let { check(metadata.data_len == it.size) { "wrong message data size" } } + ?: check(metadata.data_too_long) { "missing message data, but no too-long flag" } + } + } + + fun getOrCreateNotificationKey(): Key { + if (IdentityKeyUtil.retrieve(context, IdentityKeyUtil.NOTIFICATION_KEY) == null) { + // generate the key and store it + val key = sodium.keygen(AEAD.Method.XCHACHA20_POLY1305_IETF) + IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString) + } + return Key.fromHexString( + IdentityKeyUtil.retrieve( + context, + IdentityKeyUtil.NOTIFICATION_KEY + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt new file mode 100644 index 0000000000..b0954f2327 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistry.kt @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.notifications + +import android.content.Context +import com.goterl.lazysodium.utils.KeyPair +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import nl.komponents.kovenant.Promise +import nl.komponents.kovenant.combine.and +import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1 +import org.session.libsession.utilities.Device +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace +import org.session.libsignal.utilities.emptyPromise +import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import javax.inject.Inject +import javax.inject.Singleton + +private val TAG = PushRegistry::class.java.name + +@Singleton +class PushRegistry @Inject constructor( + @ApplicationContext private val context: Context, + private val device: Device, + private val tokenManager: TokenManager, + private val pushRegistryV2: PushRegistryV2, + private val prefs: TextSecurePreferences, + private val tokenFetcher: TokenFetcher, +) { + + private var pushRegistrationJob: Job? = null + + fun refresh(force: Boolean): Job { + Log.d(TAG, "refresh() called with: force = $force") + + pushRegistrationJob?.apply { + if (force) cancel() else if (isActive) return MainScope().launch {} + } + + return MainScope().launch(Dispatchers.IO) { + try { + register(tokenFetcher.fetch()).get() + } catch (e: Exception) { + Log.e(TAG, "register failed", e) + } + }.also { pushRegistrationJob = it } + } + + fun register(token: String?): Promise<*, Exception> { + Log.d(TAG, "refresh() called") + + if (token?.isNotEmpty() != true) return emptyPromise() + + prefs.setPushToken(token) + + val userPublicKey = prefs.getLocalNumber() ?: return emptyPromise() + val userEdKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return emptyPromise() + + return when { + prefs.isPushEnabled() -> register(token, userPublicKey, userEdKey) + tokenManager.isRegistered -> unregister(token, userPublicKey, userEdKey) + else -> emptyPromise() + } + } + + /** + * Register for push notifications. + */ + private fun register( + token: String, + publicKey: String, + userEd25519Key: KeyPair, + namespaces: List<Int> = listOf(Namespace.DEFAULT) + ): Promise<*, Exception> { + Log.d(TAG, "register() called") + + val v1 = PushRegistryV1.register( + device = device, + token = token, + publicKey = publicKey + ) fail { + Log.e(TAG, "register v1 failed", it) + } + + val v2 = pushRegistryV2.register( + device, token, publicKey, userEd25519Key, namespaces + ) fail { + Log.e(TAG, "register v2 failed", it) + } + + return v1 and v2 success { + Log.d(TAG, "register v1 & v2 success") + tokenManager.register() + } + } + + private fun unregister( + token: String, + userPublicKey: String, + userEdKey: KeyPair + ): Promise<*, Exception> = PushRegistryV1.unregister() and pushRegistryV2.unregister( + device, token, userPublicKey, userEdKey + ) fail { + Log.e(TAG, "unregisterBoth failed", it) + } success { + tokenManager.unregister() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt new file mode 100644 index 0000000000..bf16333b15 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistryV2.kt @@ -0,0 +1,116 @@ +package org.thoughtcrime.securesms.notifications + +import com.goterl.lazysodium.LazySodiumAndroid +import com.goterl.lazysodium.SodiumAndroid +import com.goterl.lazysodium.interfaces.Sign +import com.goterl.lazysodium.utils.KeyPair +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +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.Response +import org.session.libsession.messaging.sending_receiving.notifications.Server +import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionRequest +import org.session.libsession.messaging.sending_receiving.notifications.SubscriptionResponse +import org.session.libsession.messaging.sending_receiving.notifications.UnsubscribeResponse +import org.session.libsession.messaging.sending_receiving.notifications.UnsubscriptionRequest +import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.messaging.utilities.SodiumUtilities.sodium +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.Version +import org.session.libsession.utilities.Device +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace +import org.session.libsignal.utilities.retryIfNeeded +import javax.inject.Inject +import javax.inject.Singleton + +private val TAG = PushRegistryV2::class.java.name +private const val maxRetryCount = 4 + +@Singleton +class PushRegistryV2 @Inject constructor(private val pushReceiver: PushReceiver) { + fun register( + device: Device, + token: String, + publicKey: String, + userEd25519Key: KeyPair, + namespaces: List<Int> + ): Promise<SubscriptionResponse, Exception> { + val pnKey = pushReceiver.getOrCreateNotificationKey() + + val timestamp = SnodeAPI.nowWithOffset / 1000 // get timestamp in ms -> s + // if we want to support passing namespace list, here is the place to do it + val sigData = "MONITOR${publicKey}${timestamp}1${namespaces.joinToString(separator = ",")}".encodeToByteArray() + val signature = ByteArray(Sign.BYTES) + sodium.cryptoSignDetached(signature, sigData, sigData.size.toLong(), userEd25519Key.secretKey.asBytes) + val requestParameters = SubscriptionRequest( + pubkey = publicKey, + session_ed25519 = userEd25519Key.publicKey.asHexString, + namespaces = listOf(Namespace.DEFAULT), + data = true, // only permit data subscription for now (?) + service = device.service, + sig_ts = timestamp, + signature = Base64.encodeBytes(signature), + service_info = mapOf("token" to token), + enc_key = pnKey.asHexString, + ).let(Json::encodeToString) + + return retryResponseBody<SubscriptionResponse>("subscribe", requestParameters) success { + Log.d(TAG, "registerV2 success") + } + } + + fun unregister( + device: Device, + token: String, + userPublicKey: String, + userEdKey: KeyPair + ): Promise<UnsubscribeResponse, Exception> { + val timestamp = SnodeAPI.nowWithOffset / 1000 // get timestamp in ms -> s + // if we want to support passing namespace list, here is the place to do it + val sigData = "UNSUBSCRIBE${userPublicKey}${timestamp}".encodeToByteArray() + val signature = ByteArray(Sign.BYTES) + sodium.cryptoSignDetached(signature, sigData, sigData.size.toLong(), userEdKey.secretKey.asBytes) + + val requestParameters = UnsubscriptionRequest( + pubkey = userPublicKey, + session_ed25519 = userEdKey.publicKey.asHexString, + service = device.service, + sig_ts = timestamp, + signature = Base64.encodeBytes(signature), + service_info = mapOf("token" to token), + ).let(Json::encodeToString) + + return retryResponseBody<UnsubscribeResponse>("unsubscribe", requestParameters) success { + Log.d(TAG, "unregisterV2 success") + } + } + + private inline fun <reified T: Response> retryResponseBody(path: String, requestParameters: String): Promise<T, Exception> = + retryIfNeeded(maxRetryCount) { getResponseBody(path, requestParameters) } + + private inline fun <reified T: Response> getResponseBody(path: String, requestParameters: String): Promise<T, Exception> { + val server = Server.LATEST + val url = "${server.url}/$path" + val body = RequestBody.create(MediaType.get("application/json"), requestParameters) + val request = Request.Builder().url(url).post(body).build() + + return OnionRequestAPI.sendOnionRequest( + request, + server.url, + server.publicKey, + Version.V4 + ).map { response -> + response.body!!.inputStream() + .let { Json.decodeFromStream<T>(it) } + .also { if (it.isFailure()) throw Exception("error: ${it.message}.") } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index 06ea38b79b..cf0e04ddf4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -26,25 +26,35 @@ import android.os.Bundle; import androidx.core.app.RemoteInput; +import org.session.libsession.messaging.messages.ExpirationConfiguration; import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage; import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; import org.session.libsession.messaging.messages.visible.VisibleMessage; import org.session.libsession.messaging.sending_receiving.MessageSender; +import org.session.libsession.snode.SnodeAPI; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.Storage; import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.mms.MmsException; import java.util.Collections; import java.util.List; +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; +import network.loki.messenger.libsession_util.util.ExpiryMode; + /** * Get the response text from the Wearable Device and sends an message as a reply */ +@AndroidEntryPoint public class RemoteReplyReceiver extends BroadcastReceiver { public static final String TAG = RemoteReplyReceiver.class.getSimpleName(); @@ -52,6 +62,15 @@ public class RemoteReplyReceiver extends BroadcastReceiver { public static final String ADDRESS_EXTRA = "address"; public static final String REPLY_METHOD = "reply_method"; + @Inject + ThreadDatabase threadDatabase; + @Inject + MmsDatabase mmsDatabase; + @Inject + SmsDatabase smsDatabase; + @Inject + Storage storage; + @SuppressLint("StaticFieldLeak") @Override public void onReceive(final Context context, Intent intent) { @@ -73,17 +92,20 @@ public class RemoteReplyReceiver extends BroadcastReceiver { @Override protected Void doInBackground(Void... params) { Recipient recipient = Recipient.from(context, address, false); - ThreadDatabase threadDatabase = DatabaseComponent.get(context).threadDatabase(); long threadId = threadDatabase.getOrCreateThreadIdFor(recipient); VisibleMessage message = new VisibleMessage(); - message.setSentTimestamp(System.currentTimeMillis()); + message.setSentTimestamp(SnodeAPI.getNowWithOffset()); message.setText(responseText.toString()); + ExpirationConfiguration config = storage.getExpirationConfiguration(threadId); + ExpiryMode expiryMode = config == null ? null : config.getExpiryMode(); + long expiresInMillis = expiryMode == null ? 0 : expiryMode.getExpiryMillis(); + long expireStartedAt = expiryMode instanceof ExpiryMode.AfterSend ? message.getSentTimestamp() : 0L; switch (replyMethod) { case GroupMessage: { - OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null); + OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, recipient, Collections.emptyList(), null, null, expiresInMillis, 0); try { - DatabaseComponent.get(context).mmsDatabase().insertMessageOutbox(reply, threadId, false, null, true); + mmsDatabase.insertMessageOutbox(reply, threadId, false, null, true); MessageSender.send(message, address); } catch (MmsException e) { Log.w(TAG, e); @@ -91,8 +113,8 @@ public class RemoteReplyReceiver extends BroadcastReceiver { break; } case SecureMessage: { - OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient); - DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), null, true); + OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt); + smsDatabase.insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), null, true); MessageSender.send(message, address); break; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index da0896d05a..2aaa593b58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -99,7 +99,7 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil .get(); setLargeIcon(iconBitmap); } catch (InterruptedException | ExecutionException e) { - Log.w(TAG, e); + Log.w(TAG, "get iconBitmap in getThread failed", e); setLargeIcon(getPlaceholderDrawable(context, recipient)); } } else { @@ -125,7 +125,7 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); if (privacy.isDisplayContact() && threadRecipient.isGroupRecipient()) { - String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isOpenGroupRecipient()); + String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isCommunityRecipient()); stringBuilder.append(Util.getBoldedString(displayName + ": ")); } @@ -215,7 +215,7 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); if (privacy.isDisplayContact() && threadRecipient.isGroupRecipient()) { - String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isOpenGroupRecipient()); + String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isCommunityRecipient()); stringBuilder.append(Util.getBoldedString(displayName + ": ")); } @@ -298,7 +298,7 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil .submit(64, 64) .get(); } catch (InterruptedException | ExecutionException e) { - Log.w(TAG, e); + Log.w(TAG, "getBigPicture failed", e); return Bitmap.createBitmap(64, 64, Bitmap.Config.RGB_565); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenFetcher.kt new file mode 100644 index 0000000000..5bd9ce0d8d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenFetcher.kt @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.notifications + +interface TokenFetcher { + suspend fun fetch(): String? +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenManager.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenManager.kt new file mode 100644 index 0000000000..b3db642b81 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/TokenManager.kt @@ -0,0 +1,32 @@ +package org.thoughtcrime.securesms.notifications + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import org.session.libsession.utilities.TextSecurePreferences +import javax.inject.Inject +import javax.inject.Singleton + +private const val INTERVAL: Int = 12 * 60 * 60 * 1000 + +@Singleton +class TokenManager @Inject constructor( + @ApplicationContext private val context: Context, +) { + val hasValidRegistration get() = isRegistered && !isExpired + val isRegistered get() = time > 0 + private val isExpired get() = currentTime() > time + INTERVAL + + fun register() { + time = currentTime() + } + + fun unregister() { + time = 0 + } + + private var time + get() = TextSecurePreferences.getPushRegisterTime(context) + set(value) = TextSecurePreferences.setPushRegisterTime(context, value) + + private fun currentTime() = System.currentTimeMillis() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt index f67f0fbaa6..c878a79eef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LandingActivity.kt @@ -14,6 +14,11 @@ class LandingActivity : BaseActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // We always hit this LandingActivity on launch - but if there is a previous instance of + // Session then close this activity to resume the last activity from the previous instance. + if (!isTaskRoot) { finish(); return } + val binding = ActivityLandingBinding.inflate(layoutInflater) setContentView(binding.root) setUpActionBarSessionLogo(true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt index edd1bc274a..1c10571dbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt @@ -54,6 +54,7 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel private val adapter = LinkDeviceActivityAdapter(this) private var restoreJob: Job? = null + @Deprecated("Deprecated in Java") override fun onBackPressed() { if (restoreJob?.isActive == true) return // Don't allow going back with a pending job super.onBackPressed() diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt index 2de6269536..e4e8e6a9a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt @@ -12,6 +12,7 @@ import android.view.View import android.widget.Toast import androidx.annotation.ColorInt import androidx.annotation.DrawableRes +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ActivityPnModeBinding import org.session.libsession.utilities.TextSecurePreferences @@ -19,6 +20,8 @@ import org.session.libsession.utilities.ThemeUtil import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.home.HomeActivity +import org.thoughtcrime.securesms.notifications.PushManager +import org.thoughtcrime.securesms.notifications.PushRegistry import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.PNModeView @@ -27,8 +30,13 @@ import org.thoughtcrime.securesms.util.getAccentColor import org.thoughtcrime.securesms.util.getColorWithID import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo import org.thoughtcrime.securesms.util.show +import javax.inject.Inject +@AndroidEntryPoint class PNModeActivity : BaseActionBarActivity() { + + @Inject lateinit var pushRegistry: PushRegistry + private lateinit var binding: ActivityPnModeBinding private var selectedOptionView: PNModeView? = null @@ -158,10 +166,10 @@ class PNModeActivity : BaseActionBarActivity() { return } - TextSecurePreferences.setIsUsingFCM(this, (selectedOptionView == binding.fcmOptionView)) + TextSecurePreferences.setPushEnabled(this, (selectedOptionView == binding.fcmOptionView)) val application = ApplicationContext.getInstance(this) application.startPollingIfNeeded() - application.registerForFCMIfNeeded(true) + pushRegistry.refresh(true) val intent = Intent(this, HomeActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK intent.putExtra(HomeActivity.FROM_ONBOARDING, true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt deleted file mode 100644 index 051cd7542e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt +++ /dev/null @@ -1,114 +0,0 @@ -package org.thoughtcrime.securesms.onboarding - -import android.content.Intent -import android.graphics.Typeface -import android.net.Uri -import android.os.Bundle -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.method.LinkMovementMethod -import android.text.style.ClickableSpan -import android.text.style.StyleSpan -import android.view.View -import android.widget.Toast -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.R -import network.loki.messenger.databinding.ActivityRecoveryPhraseRestoreBinding -import org.session.libsession.snode.SnodeModule -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.crypto.MnemonicCodec -import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.KeyHelper -import org.session.libsignal.utilities.hexEncodedPublicKey -import org.thoughtcrime.securesms.BaseActionBarActivity -import org.thoughtcrime.securesms.crypto.KeyPairUtilities -import org.thoughtcrime.securesms.crypto.MnemonicUtilities -import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.util.push -import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo -import javax.inject.Inject - -@AndroidEntryPoint -class RecoveryPhraseRestoreActivity : BaseActionBarActivity() { - - @Inject - lateinit var configFactory: ConfigFactory - - private lateinit var binding: ActivityRecoveryPhraseRestoreBinding - internal val database: LokiAPIDatabaseProtocol - get() = SnodeModule.shared.storage - // region Lifecycle - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setUpActionBarSessionLogo() - TextSecurePreferences.apply { - setHasViewedSeed(this@RecoveryPhraseRestoreActivity, true) - setConfigurationMessageSynced(this@RecoveryPhraseRestoreActivity, false) - setRestorationTime(this@RecoveryPhraseRestoreActivity, System.currentTimeMillis()) - setLastProfileUpdateTime(this@RecoveryPhraseRestoreActivity, System.currentTimeMillis()) - } - binding = ActivityRecoveryPhraseRestoreBinding.inflate(layoutInflater) - setContentView(binding.root) - binding.mnemonicEditText.imeOptions = binding.mnemonicEditText.imeOptions or 16777216 // Always use incognito keyboard - binding.restoreButton.setOnClickListener { restore() } - val termsExplanation = SpannableStringBuilder("By using this service, you agree to our Terms of Service and Privacy Policy") - termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 40, 56, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - termsExplanation.setSpan(object : ClickableSpan() { - - override fun onClick(widget: View) { - openURL("https://getsession.org/terms-of-service/") - } - }, 40, 56, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - termsExplanation.setSpan(object : ClickableSpan() { - - override fun onClick(widget: View) { - openURL("https://getsession.org/privacy-policy/") - } - }, 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - binding.termsTextView.movementMethod = LinkMovementMethod.getInstance() - binding.termsTextView.text = termsExplanation - } - // endregion - - // region Interaction - private fun restore() { - val mnemonic = binding.mnemonicEditText.text.toString() - try { - // This is here to resolve a case where the app restarts before a user completes onboarding - // which can result in an invalid database state - database.clearAllLastMessageHashes() - database.clearReceivedMessageHashValues() - - val loadFileContents: (String) -> String = { fileName -> - MnemonicUtilities.loadFileContents(this, fileName) - } - val hexEncodedSeed = MnemonicCodec(loadFileContents).decode(mnemonic) - val seed = Hex.fromStringCondensed(hexEncodedSeed) - val keyPairGenerationResult = KeyPairUtilities.generate(seed) - val x25519KeyPair = keyPairGenerationResult.x25519KeyPair - KeyPairUtilities.store(this, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair) - configFactory.keyPairChanged() - val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey - val registrationID = KeyHelper.generateRegistrationId(false) - TextSecurePreferences.setLocalRegistrationId(this, registrationID) - TextSecurePreferences.setLocalNumber(this, userHexEncodedPublicKey) - val intent = Intent(this, DisplayNameActivity::class.java) - push(intent) - } catch (e: Exception) { - val message = if (e is MnemonicCodec.DecodingError) e.description else MnemonicCodec.DecodingError.Generic.description - return Toast.makeText(this, message, Toast.LENGTH_SHORT).show() - } - } - - private fun openURL(url: String) { - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - startActivity(intent) - } catch (e: Exception) { - Toast.makeText(this, R.string.invalid_url, Toast.LENGTH_SHORT).show() - } - } - // endregion -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt index 6e082e0008..13e5b51f0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt @@ -35,6 +35,8 @@ import javax.inject.Inject @AndroidEntryPoint class RegisterActivity : BaseActionBarActivity() { + private val temporarySeedKey = "TEMPORARY_SEED_KEY" + @Inject lateinit var configFactory: ConfigFactory @@ -77,16 +79,23 @@ class RegisterActivity : BaseActionBarActivity() { }, 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) binding.termsTextView.movementMethod = LinkMovementMethod.getInstance() binding.termsTextView.text = termsExplanation - updateKeyPair() + updateKeyPair(savedInstanceState?.getByteArray(temporarySeedKey)) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + seed?.let { tempSeed -> + outState.putByteArray(temporarySeedKey, tempSeed) + } } // endregion // region Updating - private fun updateKeyPair() { - val keyPairGenerationResult = KeyPairUtilities.generate() - seed = keyPairGenerationResult.seed + private fun updateKeyPair(temporaryKey: ByteArray?) { + val keyPairGenerationResult = temporaryKey?.let(KeyPairUtilities::generate) ?: KeyPairUtilities.generate() + seed = keyPairGenerationResult.seed ed25519KeyPair = keyPairGenerationResult.ed25519KeyPair - x25519KeyPair = keyPairGenerationResult.x25519KeyPair + x25519KeyPair = keyPairGenerationResult.x25519KeyPair } private fun updatePublicKeyTextView() { @@ -125,7 +134,6 @@ class RegisterActivity : BaseActionBarActivity() { // which can result in an invalid database state database.clearAllLastMessageHashes() database.clearReceivedMessageHashValues() - KeyPairUtilities.store(this, seed!!, ed25519KeyPair!!, x25519KeyPair!!) configFactory.keyPairChanged() val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt index 37a54a4afc..31f2782f8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt @@ -15,10 +15,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.DialogClearAllDataBinding +import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.snode.SnodeAPI import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.createSessionDialog +import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities class ClearAllDataDialog : DialogFragment() { @@ -44,9 +46,9 @@ class ClearAllDataDialog : DialogFragment() { private fun createView(): View { binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext())) - val device = RadioOption("deviceOnly", requireContext().getString(R.string.dialog_clear_all_data_clear_device_only)) - val network = RadioOption("deviceAndNetwork", requireContext().getString(R.string.dialog_clear_all_data_clear_device_and_network)) - var selectedOption = device + val device = radioOption("deviceOnly", R.string.dialog_clear_all_data_clear_device_only) + val network = radioOption("deviceAndNetwork", R.string.dialog_clear_all_data_clear_device_and_network) + var selectedOption: RadioOption<String> = device val optionAdapter = RadioOptionAdapter { selectedOption = it } binding.recyclerView.apply { itemAnimator = null @@ -115,6 +117,10 @@ class ClearAllDataDialog : DialogFragment() { } else { // finish val result = try { + val openGroups = DatabaseComponent.get(requireContext()).lokiThreadDatabase().getAllOpenGroups() + openGroups.map { it.value.server }.toSet().forEach { server -> + OpenGroupApi.deleteAllInboxMessages(server).get() + } SnodeAPI.deleteAllMessages().get() } catch (e: Exception) { null diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt index f6efd041cd..e7e5f2d5f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt @@ -5,9 +5,14 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle +import android.widget.ProgressBar +import android.widget.TextView import android.widget.Toast +import androidx.core.view.isInvisible import androidx.preference.Preference + import network.loki.messenger.R +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.permissions.Permissions @@ -67,6 +72,19 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() { } } + private fun updateExportButtonAndProgressBarUI(exportJobRunning: Boolean) { + this.activity?.runOnUiThread(Runnable { + // Change export logs button text + val exportLogsButton = this.activity?.findViewById(R.id.export_logs_button) as TextView? + if (exportLogsButton == null) { Log.w("Loki", "Could not find export logs button view.") } + exportLogsButton?.text = if (exportJobRunning) getString(R.string.cancel) else getString(R.string.activity_help_settings__export_logs) + + // Show progress bar + val exportProgressBar = this.activity?.findViewById(R.id.export_progress_bar) as ProgressBar? + exportProgressBar?.isInvisible = !exportJobRunning + }) + } + private fun shareLogs() { Permissions.with(this) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) @@ -76,7 +94,7 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() { Toast.makeText(requireActivity(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show() } .onAllGranted { - ShareLogsDialog().show(parentFragmentManager,"Share Logs Dialog") + ShareLogsDialog(::updateExportButtonAndProgressBarUI).show(parentFragmentManager,"Share Logs Dialog") } .execute() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt index af039a4fdb..b18859ea07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationSettingsActivity.kt @@ -1,10 +1,12 @@ package org.thoughtcrime.securesms.preferences import android.os.Bundle +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.preferences.NotificationsPreferenceFragment +@AndroidEntryPoint class NotificationSettingsActivity : PassphraseRequiredActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java deleted file mode 100644 index 4eaa58e815..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java +++ /dev/null @@ -1,182 +0,0 @@ -package org.thoughtcrime.securesms.preferences; - -import static android.app.Activity.RESULT_OK; - -import static org.thoughtcrime.securesms.preferences.ListPreferenceDialogKt.listPreferenceDialog; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.Intent; -import android.media.Ringtone; -import android.media.RingtoneManager; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Bundle; -import android.provider.Settings; -import android.text.TextUtils; - -import androidx.annotation.Nullable; -import androidx.preference.ListPreference; -import androidx.preference.Preference; - -import org.session.libsession.utilities.TextSecurePreferences; -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; -import org.thoughtcrime.securesms.notifications.NotificationChannels; - -import network.loki.messenger.R; - -public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragment { - - @SuppressWarnings("unused") - private static final String TAG = NotificationsPreferenceFragment.class.getSimpleName(); - - @Override - public void onCreate(Bundle paramBundle) { - super.onCreate(paramBundle); - - // Set up FCM toggle - String fcmKey = "pref_key_use_fcm"; - ((SwitchPreferenceCompat)findPreference(fcmKey)).setChecked(TextSecurePreferences.isUsingFCM(getContext())); - this.findPreference(fcmKey) - .setOnPreferenceChangeListener((preference, newValue) -> { - TextSecurePreferences.setIsUsingFCM(getContext(), (boolean) newValue); - ApplicationContext.getInstance(getContext()).registerForFCMIfNeeded(true); - return true; - }); - - if (NotificationChannels.supported()) { - TextSecurePreferences.setNotificationRingtone(getContext(), NotificationChannels.getMessageRingtone(getContext()).toString()); - TextSecurePreferences.setNotificationVibrateEnabled(getContext(), NotificationChannels.getMessageVibrate(getContext())); - } - this.findPreference(TextSecurePreferences.RINGTONE_PREF) - .setOnPreferenceChangeListener(new RingtoneSummaryListener()); - this.findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF) - .setOnPreferenceChangeListener(new NotificationPrivacyListener()); - this.findPreference(TextSecurePreferences.VIBRATE_PREF) - .setOnPreferenceChangeListener((preference, newValue) -> { - NotificationChannels.updateMessageVibrate(getContext(), (boolean) newValue); - return true; - }); - - this.findPreference(TextSecurePreferences.RINGTONE_PREF) - .setOnPreferenceClickListener(preference -> { - Uri current = TextSecurePreferences.getNotificationRingtone(getContext()); - - Intent intent = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER); - intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true); - intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true); - intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION); - intent.putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, Settings.System.DEFAULT_NOTIFICATION_URI); - intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current); - - startActivityForResult(intent, 1); - - return true; - }); - - this.findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF) - .setOnPreferenceClickListener(preference -> { - ListPreference listPreference = (ListPreference) preference; - listPreference.setDialogMessage(R.string.preferences_notifications__content_message); - listPreferenceDialog(getContext(), listPreference, () -> { - initializeListSummary(findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)); - return null; - }); - return true; - }); - - initializeListSummary((ListPreference) findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)); - - if (NotificationChannels.supported()) { - this.findPreference(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF) - .setOnPreferenceClickListener(preference -> { - Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS); - intent.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationChannels.getMessagesChannel(getContext())); - intent.putExtra(Settings.EXTRA_APP_PACKAGE, getContext().getPackageName()); - startActivity(intent); - return true; - }); - } - - initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF)); - initializeMessageVibrateSummary((SwitchPreferenceCompat)findPreference(TextSecurePreferences.VIBRATE_PREF)); - } - - @Override - public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { - addPreferencesFromResource(R.xml.preferences_notifications); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == 1 && resultCode == RESULT_OK && data != null) { - Uri uri = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI); - - if (Settings.System.DEFAULT_NOTIFICATION_URI.equals(uri)) { - NotificationChannels.updateMessageRingtone(getContext(), uri); - TextSecurePreferences.removeNotificationRingtone(getContext()); - } else { - uri = uri == null ? Uri.EMPTY : uri; - NotificationChannels.updateMessageRingtone(getContext(), uri); - TextSecurePreferences.setNotificationRingtone(getContext(), uri.toString()); - } - - initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF)); - } - } - - private class RingtoneSummaryListener implements Preference.OnPreferenceChangeListener { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - Uri value = (Uri) newValue; - - if (value == null || TextUtils.isEmpty(value.toString())) { - preference.setSummary(R.string.preferences__silent); - } else { - Ringtone tone = RingtoneManager.getRingtone(getActivity(), value); - - if (tone != null) { - preference.setSummary(tone.getTitle(getActivity())); - } - } - - return true; - } - } - - private void initializeRingtoneSummary(Preference pref) { - RingtoneSummaryListener listener = (RingtoneSummaryListener) pref.getOnPreferenceChangeListener(); - Uri uri = TextSecurePreferences.getNotificationRingtone(getContext()); - - listener.onPreferenceChange(pref, uri); - } - - private void initializeMessageVibrateSummary(SwitchPreferenceCompat pref) { - pref.setChecked(TextSecurePreferences.isNotificationVibrateEnabled(getContext())); - } - - public static CharSequence getSummary(Context context) { - final int onCapsResId = R.string.ApplicationPreferencesActivity_On; - final int offCapsResId = R.string.ApplicationPreferencesActivity_Off; - - return context.getString(TextSecurePreferences.isNotificationsEnabled(context) ? onCapsResId : offCapsResId); - } - - private class NotificationPrivacyListener extends ListSummaryListener { - @SuppressLint("StaticFieldLeak") - @Override - public boolean onPreferenceChange(Preference preference, Object value) { - new AsyncTask<Void, Void, Void>() { - @Override - protected Void doInBackground(Void... params) { - ApplicationContext.getInstance(getActivity()).messageNotifier.updateNotification(getActivity()); - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - - return super.onPreferenceChange(preference, value); - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt new file mode 100644 index 0000000000..fa6461acc4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.kt @@ -0,0 +1,183 @@ +package org.thoughtcrime.securesms.preferences + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import android.net.Uri +import android.os.AsyncTask +import android.os.Bundle +import android.provider.Settings +import android.text.TextUtils +import androidx.lifecycle.lifecycleScope +import androidx.preference.ListPreference +import androidx.preference.Preference +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.isNotificationsEnabled +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.components.SwitchPreferenceCompat +import org.thoughtcrime.securesms.notifications.NotificationChannels +import org.thoughtcrime.securesms.notifications.PushRegistry +import javax.inject.Inject + +@AndroidEntryPoint +class NotificationsPreferenceFragment : ListSummaryPreferenceFragment() { + @Inject + lateinit var pushRegistry: PushRegistry + @Inject + lateinit var prefs: TextSecurePreferences + + override fun onCreate(paramBundle: Bundle?) { + super.onCreate(paramBundle) + + // Set up FCM toggle + val fcmKey = "pref_key_use_fcm" + val fcmPreference: SwitchPreferenceCompat = findPreference(fcmKey)!! + fcmPreference.isChecked = prefs.isPushEnabled() + fcmPreference.setOnPreferenceChangeListener { _: Preference, newValue: Any -> + prefs.setPushEnabled(newValue as Boolean) + val job = pushRegistry.refresh(true) + + fcmPreference.isEnabled = false + + lifecycleScope.launch(Dispatchers.IO) { + job.join() + + withContext(Dispatchers.Main) { + fcmPreference.isEnabled = true + } + } + + true + } + if (NotificationChannels.supported()) { + prefs.setNotificationRingtone( + NotificationChannels.getMessageRingtone(requireContext()).toString() + ) + prefs.setNotificationVibrateEnabled( + NotificationChannels.getMessageVibrate(requireContext()) + ) + } + findPreference<Preference>(TextSecurePreferences.RINGTONE_PREF)!!.onPreferenceChangeListener = RingtoneSummaryListener() + findPreference<Preference>(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)!!.onPreferenceChangeListener = NotificationPrivacyListener() + findPreference<Preference>(TextSecurePreferences.VIBRATE_PREF)!!.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _: Preference?, newValue: Any -> + NotificationChannels.updateMessageVibrate(requireContext(), newValue as Boolean) + true + } + findPreference<Preference>(TextSecurePreferences.RINGTONE_PREF)!!.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + val current = prefs.getNotificationRingtone() + val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER) + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true) + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true) + intent.putExtra( + RingtoneManager.EXTRA_RINGTONE_TYPE, + RingtoneManager.TYPE_NOTIFICATION + ) + intent.putExtra( + RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, + Settings.System.DEFAULT_NOTIFICATION_URI + ) + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current) + startActivityForResult(intent, 1) + true + } + findPreference<Preference>(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)!!.onPreferenceClickListener = + Preference.OnPreferenceClickListener { preference: Preference -> + val listPreference = preference as ListPreference + listPreference.setDialogMessage(R.string.preferences_notifications__content_message) + listPreferenceDialog(requireContext(), listPreference) { + initializeListSummary(findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)) + } + true + } + initializeListSummary(findPreference<Preference>(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF) as ListPreference?) + if (NotificationChannels.supported()) { + findPreference<Preference>(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF)!!.onPreferenceClickListener = + Preference.OnPreferenceClickListener { + val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + intent.putExtra( + Settings.EXTRA_CHANNEL_ID, NotificationChannels.getMessagesChannel(requireContext()) + ) + intent.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) + startActivity(intent) + true + } + } + initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF)) + initializeMessageVibrateSummary(findPreference<Preference>(TextSecurePreferences.VIBRATE_PREF) as SwitchPreferenceCompat?) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.preferences_notifications) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == 1 && resultCode == Activity.RESULT_OK && data != null) { + var uri = data.getParcelableExtra<Uri>(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) + if (Settings.System.DEFAULT_NOTIFICATION_URI == uri) { + NotificationChannels.updateMessageRingtone(requireContext(), uri) + prefs.removeNotificationRingtone() + } else { + uri = uri ?: Uri.EMPTY + NotificationChannels.updateMessageRingtone(requireContext(), uri) + prefs.setNotificationRingtone(uri.toString()) + } + initializeRingtoneSummary(findPreference(TextSecurePreferences.RINGTONE_PREF)) + } + } + + private inner class RingtoneSummaryListener : Preference.OnPreferenceChangeListener { + override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { + val value = newValue as? Uri + if (value == null || TextUtils.isEmpty(value.toString())) { + preference.setSummary(R.string.preferences__silent) + } else { + RingtoneManager.getRingtone(activity, value) + ?.getTitle(activity) + ?.let { preference.summary = it } + + } + return true + } + } + + private fun initializeRingtoneSummary(pref: Preference?) { + val listener = pref!!.onPreferenceChangeListener as RingtoneSummaryListener? + val uri = prefs.getNotificationRingtone() + listener!!.onPreferenceChange(pref, uri) + } + + private fun initializeMessageVibrateSummary(pref: SwitchPreferenceCompat?) { + pref!!.isChecked = prefs.isNotificationVibrateEnabled() + } + + private inner class NotificationPrivacyListener : ListSummaryListener() { + @SuppressLint("StaticFieldLeak") + override fun onPreferenceChange(preference: Preference, value: Any): Boolean { + object : AsyncTask<Void?, Void?, Void?>() { + override fun doInBackground(vararg params: Void?): Void? { + ApplicationContext.getInstance(activity).messageNotifier.updateNotification(activity!!) + return null + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + return super.onPreferenceChange(preference, value) + } + } + + companion object { + @Suppress("unused") + private val TAG = NotificationsPreferenceFragment::class.java.simpleName + fun getSummary(context: Context): CharSequence = when (isNotificationsEnabled(context)) { + true -> R.string.ApplicationPreferencesActivity_On + false -> R.string.ApplicationPreferencesActivity_Off + }.let(context::getString) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsActivity.kt index b8606a3d54..de136694ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsActivity.kt @@ -1,9 +1,11 @@ package org.thoughtcrime.securesms.preferences import android.os.Bundle +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +@AndroidEntryPoint class PrivacySettingsActivity : PassphraseRequiredActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt index eaf48f8688..21b12496bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt @@ -8,6 +8,9 @@ import android.os.Build import android.os.Bundle import android.provider.Settings import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceDataStore +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.BuildConfig import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences @@ -15,13 +18,19 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.isPasswo import org.session.libsession.utilities.TextSecurePreferences.Companion.setScreenLockEnabled import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.components.SwitchPreferenceCompat +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.areNotificationsEnabled import org.thoughtcrime.securesms.util.IntentUtils +import javax.inject.Inject +@AndroidEntryPoint class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() { + + @Inject lateinit var configFactory: ConfigFactory + override fun onCreate(paramBundle: Bundle?) { super.onCreate(paramBundle) findPreference<Preference>(TextSecurePreferences.SCREEN_LOCK)!! @@ -30,6 +39,33 @@ class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() { .onPreferenceChangeListener = TypingIndicatorsToggleListener() findPreference<Preference>(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED)!! .onPreferenceChangeListener = CallToggleListener(this) { setCall(it) } + findPreference<PreferenceCategory>(getString(R.string.preferences__message_requests_category))?.let { category -> + when (val user = configFactory.user) { + null -> category.isVisible = false + else -> SwitchPreferenceCompat(requireContext()).apply { + key = TextSecurePreferences.ALLOW_MESSAGE_REQUESTS + preferenceDataStore = object : PreferenceDataStore() { + + override fun getBoolean(key: String?, defValue: Boolean): Boolean { + if (key == TextSecurePreferences.ALLOW_MESSAGE_REQUESTS) { + return user.getCommunityMessageRequests() + } + return super.getBoolean(key, defValue) + } + + override fun putBoolean(key: String?, value: Boolean) { + if (key == TextSecurePreferences.ALLOW_MESSAGE_REQUESTS) { + user.setCommunityMessageRequests(value) + return + } + super.putBoolean(key, value) + } + } + title = getString(R.string.preferences__message_requests_title) + summary = getString(R.string.preferences__message_requests_summary) + }.let(category::addPreference) + } + } initializeVisibility() } @@ -59,6 +95,7 @@ class PrivacySettingsPreferenceFragment : ListSummaryPreferenceFragment() { } } + @Deprecated("Deprecated in Java") override fun onRequestPermissionsResult( requestCode: Int, permissions: Array<String>, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt index 4bb69c4c14..f80acee64e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt @@ -3,53 +3,120 @@ package org.thoughtcrime.securesms.preferences import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import network.loki.messenger.R import network.loki.messenger.databinding.ItemSelectableBinding +import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.ui.GetString +import java.util.Objects -class RadioOptionAdapter( - var selectedOptionPosition: Int = 0, - private val onClickListener: (RadioOption) -> Unit -) : ListAdapter<RadioOption, RadioOptionAdapter.ViewHolder>(RadioOptionDiffer()) { +class RadioOptionAdapter<T>( + private var selectedOptionPosition: Int = 0, + private val onClickListener: (RadioOption<T>) -> Unit +) : ListAdapter<RadioOption<T>, RadioOptionAdapter.ViewHolder<T>>(RadioOptionDiffer()) { - class RadioOptionDiffer: DiffUtil.ItemCallback<RadioOption>() { - override fun areItemsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.title == newItem.title - override fun areContentsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.value == newItem.value + class RadioOptionDiffer<T>: DiffUtil.ItemCallback<RadioOption<T>>() { + override fun areItemsTheSame(oldItem: RadioOption<T>, newItem: RadioOption<T>) = oldItem.title == newItem.title + override fun areContentsTheSame(oldItem: RadioOption<T>, newItem: RadioOption<T>) = Objects.equals(oldItem.value,newItem.value) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_selectable, parent, false) - return ViewHolder(itemView) - } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<T> = + LayoutInflater.from(parent.context).inflate(R.layout.item_selectable, parent, false) + .let(::ViewHolder) - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val option = getItem(position) - val isSelected = position == selectedOptionPosition - holder.bind(option, isSelected) { + override fun onBindViewHolder(holder: ViewHolder<T>, position: Int) { + holder.bind( + option = getItem(position), + isSelected = position == selectedOptionPosition + ) { onClickListener(it) - selectedOptionPosition = position - notifyItemRangeChanged(0, itemCount) + setSelectedPosition(position) } } - class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { + fun setSelectedPosition(selectedPosition: Int) { + notifyItemChanged(selectedOptionPosition) + selectedOptionPosition = selectedPosition + notifyItemChanged(selectedOptionPosition) + } + class ViewHolder<T>(itemView: View): RecyclerView.ViewHolder(itemView) { val glide = GlideApp.with(itemView) val binding = ItemSelectableBinding.bind(itemView) - fun bind(option: RadioOption, isSelected: Boolean, toggleSelection: (RadioOption) -> Unit) { - binding.titleTextView.text = option.title - binding.root.setOnClickListener { toggleSelection(option) } + fun bind(option: RadioOption<T>, isSelected: Boolean, toggleSelection: (RadioOption<T>) -> Unit) { + val alpha = if (option.enabled) 1f else 0.5f + binding.root.isEnabled = option.enabled + binding.root.contentDescription = option.contentDescription?.string(itemView.context) + binding.titleTextView.alpha = alpha + binding.subtitleTextView.alpha = alpha + binding.selectButton.alpha = alpha + + binding.titleTextView.text = option.title.string(itemView.context) + binding.subtitleTextView.text = option.subtitle?.string(itemView.context).also { + binding.subtitleTextView.isVisible = !it.isNullOrBlank() + } + binding.selectButton.isSelected = isSelected + if (option.enabled) { + binding.root.setOnClickListener { toggleSelection(option) } + } } } } -data class RadioOption( - val value: String, - val title: String +data class RadioOption<out T>( + val value: T, + val title: GetString, + val subtitle: GetString? = null, + val enabled: Boolean = true, + val contentDescription: GetString? = null ) + +fun <T> radioOption(value: T, @StringRes title: Int, configure: RadioOptionBuilder<T>.() -> Unit = {}) = + radioOption(value, GetString(title), configure) + +fun <T> radioOption(value: T, title: String, configure: RadioOptionBuilder<T>.() -> Unit = {}) = + radioOption(value, GetString(title), configure) + +fun <T> radioOption(value: T, title: GetString, configure: RadioOptionBuilder<T>.() -> Unit = {}) = + RadioOptionBuilder(value, title).also { it.configure() }.build() + +class RadioOptionBuilder<out T>( + val value: T, + val title: GetString +) { + var subtitle: GetString? = null + var enabled: Boolean = true + var contentDescription: GetString? = null + + fun subtitle(string: String) { + subtitle = GetString(string) + } + + fun subtitle(@StringRes stringRes: Int) { + subtitle = GetString(stringRes) + } + + fun contentDescription(string: String) { + contentDescription = GetString(string) + } + + fun contentDescription(@StringRes stringRes: Int) { + contentDescription = GetString(stringRes) + } + + fun build() = RadioOption( + value, + title, + subtitle, + enabled, + contentDescription + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 5f24855760..b66df5d255 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -17,6 +17,7 @@ import android.view.ActionMode import android.view.Menu import android.view.MenuItem import android.view.View +import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.core.view.isVisible @@ -30,12 +31,13 @@ import nl.komponents.kovenant.all import nl.komponents.kovenant.ui.alwaysUi import nl.komponents.kovenant.ui.successUi import org.session.libsession.avatars.AvatarHelper +import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.snode.SnodeAPI -import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.utilities.* import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.getProperty import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.avatar.AvatarSelection import org.thoughtcrime.securesms.components.ProfilePictureView @@ -106,7 +108,9 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { helpButton.setOnClickListener { showHelp() } seedButton.setOnClickListener { showSeed() } clearAllDataButton.setOnClickListener { clearAllData() } - versionTextView.text = String.format(getString(R.string.version_s), "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + + val gitCommitFirstSixChars = BuildConfig.GIT_HASH.take(6) + versionTextView.text = String.format(getString(R.string.version_s), "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - $gitCommitFirstSixChars)") } } @@ -151,6 +155,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } } + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) when (requestCode) { @@ -202,6 +207,21 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { binding.displayNameEditText.selectAll() binding.displayNameEditText.requestFocus() inputMethodManager.showSoftInput(binding.displayNameEditText, 0) + + // Save the updated display name when the user presses enter on the soft keyboard + binding.displayNameEditText.setOnEditorActionListener { v, actionId, event -> + when (actionId) { + // Note: IME_ACTION_DONE is how we've configured the soft keyboard to respond, + // while IME_ACTION_UNSPECIFIED is what triggers when we hit enter on a + // physical keyboard. + EditorInfo.IME_ACTION_DONE, EditorInfo.IME_ACTION_UNSPECIFIED -> { + saveDisplayName() + displayNameEditActionMode?.finish() + true + } + else -> false + } + } } else { inputMethodManager.hideSoftInputFromWindow(binding.displayNameEditText.windowToken, 0) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt index 2dc5e75d98..9bfc1dabf2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt @@ -11,55 +11,73 @@ import android.os.Bundle import android.os.Environment import android.provider.MediaStore import android.webkit.MimeTypeMap +import android.widget.ProgressBar +import android.widget.TextView import android.widget.Toast +import androidx.core.view.isInvisible import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope + import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext + import network.loki.messenger.BuildConfig import network.loki.messenger.R + import org.session.libsignal.utilities.ExternalStorageUtil import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.util.FileProviderUtil import org.thoughtcrime.securesms.util.StreamUtil + import java.io.File import java.io.FileOutputStream import java.io.IOException import java.util.Objects import java.util.concurrent.TimeUnit -class ShareLogsDialog : DialogFragment() { +class ShareLogsDialog(private val updateCallback: (Boolean)->Unit): DialogFragment() { + + private val TAG = "ShareLogsDialog" private var shareJob: Job? = null override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { title(R.string.dialog_share_logs_title) text(R.string.dialog_share_logs_explanation) - button(R.string.share, dismiss = false) { shareLogs() } - cancelButton { dismiss() } + button(R.string.share, dismiss = false) { runShareLogsJob() } + cancelButton { updateCallback(false) } } - private fun shareLogs() { + // If the share logs dialog loses focus the job gets cancelled so we'll update the UI state + override fun onPause() { + super.onPause() + updateCallback(false) + } + + private fun runShareLogsJob() { + // Cancel any existing share job that might already be running to start anew shareJob?.cancel() + + updateCallback(true) + shareJob = lifecycleScope.launch(Dispatchers.IO) { val persistentLogger = ApplicationContext.getInstance(context).persistentLogger try { + Log.d(TAG, "Starting share logs job...") + val context = requireContext() val outputUri: Uri = ExternalStorageUtil.getDownloadUri() - val mediaUri = getExternalFile() - if (mediaUri == null) { - // show toast saying media saved - dismiss() - return@launch - } + val mediaUri = getExternalFile() ?: return@launch val inputStream = persistentLogger.logs.get().byteInputStream() val updateValues = ContentValues() + + // Add details into the output or media files as appropriate if (outputUri.scheme == ContentResolver.SCHEME_FILE) { FileOutputStream(mediaUri.path).use { outputStream -> StreamUtil.copy(inputStream, outputStream) @@ -73,6 +91,7 @@ class ShareLogsDialog : DialogFragment() { } } } + if (Build.VERSION.SDK_INT > 28) { updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0) } @@ -95,13 +114,35 @@ class ShareLogsDialog : DialogFragment() { } startActivity(Intent.createChooser(shareIntent, getString(R.string.share))) } - - dismiss() } catch (e: Exception) { withContext(Main) { Log.e("Loki", "Error saving logs", e) Toast.makeText(context,"Error saving logs", Toast.LENGTH_LONG).show() } + } + }.also { shareJob -> + shareJob.invokeOnCompletion { handler -> + // Note: Don't show Toasts here directly - use `withContext(Main)` or such if req'd + handler?.message.let { msg -> + if (shareJob.isCancelled) { + if (msg.isNullOrBlank()) { + Log.w(TAG, "Share logs job was cancelled.") + } else { + Log.d(TAG, "Share logs job was cancelled. Reason: $msg") + } + + } + else if (shareJob.isCompleted) { + Log.d(TAG, "Share logs job completed. Msg: $msg") + } + else { + Log.w(TAG, "Share logs job finished while still Active. Msg: $msg") + } + } + + // Regardless of the job's success it has now completed so update the UI + updateCallback(false) + dismiss() } } @@ -158,5 +199,4 @@ class ShareLogsDialog : DialogFragment() { return context.contentResolver.insert(outputUri, contentValues) } - } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ContactPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ContactPreference.java deleted file mode 100644 index f5417f3e91..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ContactPreference.java +++ /dev/null @@ -1,81 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets; - - -import android.content.Context; -import android.graphics.PorterDuff; -import androidx.preference.Preference; -import androidx.preference.PreferenceViewHolder; -import android.util.AttributeSet; -import android.view.View; -import android.widget.ImageView; - -import network.loki.messenger.R; - -public class ContactPreference extends Preference { - - private ImageView messageButton; - - private Listener listener; - private boolean secure; - - public ContactPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - initialize(); - } - - public ContactPreference(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(); - } - - public ContactPreference(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public ContactPreference(Context context) { - super(context); - initialize(); - } - - private void initialize() { - setWidgetLayoutResource(R.layout.recipient_preference_contact_widget); - } - - @Override - public void onBindViewHolder(PreferenceViewHolder view) { - super.onBindViewHolder(view); - - this.messageButton = (ImageView) view.findViewById(R.id.message); - - if (listener != null) setListener(listener); - setSecure(secure); - } - - public void setSecure(boolean secure) { - this.secure = secure; - - int color; - - if (secure) { - color = getContext().getResources().getColor(R.color.textsecure_primary); - } else { - color = getContext().getResources().getColor(R.color.grey_600); - } - - if (messageButton != null) messageButton.setColorFilter(color, PorterDuff.Mode.SRC_IN); - } - - public void setListener(Listener listener) { - this.listener = listener; - - if (this.messageButton != null) this.messageButton.setOnClickListener(v -> listener.onMessageClicked()); - } - - public interface Listener { - public void onMessageClicked(); - public void onSecureCallClicked(); - public void onInSecureCallClicked(); - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/NotificationSettingsPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/NotificationSettingsPreference.kt deleted file mode 100644 index 3c2e72779d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/NotificationSettingsPreference.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets - -import android.content.Context -import android.util.AttributeSet -import android.widget.FrameLayout - -class NotificationSettingsPreference @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : FrameLayout(context, attrs) { - - override fun onFinishInflate() { - super.onFinishInflate() - // TODO: if we want do the spans - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProgressPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProgressPreference.java deleted file mode 100644 index 52a88c5664..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ProgressPreference.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets; - - -import android.content.Context; -import androidx.preference.Preference; -import androidx.preference.PreferenceViewHolder; -import android.util.AttributeSet; -import android.view.View; -import android.widget.TextView; - -import network.loki.messenger.R; - -public class ProgressPreference extends Preference { - - private View container; - private TextView progressText; - - public ProgressPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - initialize(); - } - - public ProgressPreference(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(); - } - - public ProgressPreference(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public ProgressPreference(Context context) { - super(context); - initialize(); - } - - private void initialize() { - setWidgetLayoutResource(R.layout.preference_widget_progress); - } - - @Override - public void onBindViewHolder(PreferenceViewHolder view) { - super.onBindViewHolder(view); - - this.container = view.findViewById(R.id.container); - this.progressText = (TextView) view.findViewById(R.id.progress_text); - - this.container.setVisibility(View.GONE); - } - - public void setProgress(int count) { - container.setVisibility(View.VISIBLE); - progressText.setText(getContext().getString(R.string.ProgressPreference_d_messages_so_far, count)); - } - - public void setProgressVisible(boolean visible) { - container.setVisibility(visible ? View.VISIBLE : View.GONE); - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ThisMessageEmojiPageModel.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ThisMessageEmojiPageModel.java deleted file mode 100644 index 0e70c41a90..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ThisMessageEmojiPageModel.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.thoughtcrime.securesms.reactions.any; - -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.annimon.stream.Stream; - -import org.thoughtcrime.securesms.components.emoji.Emoji; -import org.thoughtcrime.securesms.components.emoji.EmojiPageModel; -import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel; - -import java.util.List; - -import network.loki.messenger.R; - -/** - * Contains the Emojis that have been used in reactions for a given message. - */ -class ThisMessageEmojiPageModel implements EmojiPageModel { - - private final List<String> emoji; - - ThisMessageEmojiPageModel(@NonNull List<String> emoji) { - this.emoji = emoji; - } - - @Override - public String getKey() { - return RecentEmojiPageModel.KEY; - } - - @Override - public int getIconAttr() { - return R.attr.emoji_category_recent; - } - - @Override - public @NonNull List<String> getEmoji() { - return emoji; - } - - @Override - public @NonNull List<Emoji> getDisplayEmoji() { - return Stream.of(getEmoji()).map(Emoji::new).toList(); - } - - @Override - public boolean hasSpriteMap() { - return false; - } - - @Override - public @Nullable Uri getSpriteUri() { - return null; - } - - @Override - public boolean isDynamic() { - return true; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index dd013afa74..8a7a2dfd0f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -1,5 +1,17 @@ package org.thoughtcrime.securesms.repository +import network.loki.messenger.libsession_util.util.ExpiryMode + +import android.content.ContentResolver +import android.content.Context +import app.cash.copper.Query +import app.cash.copper.flow.observeQuery +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.MessageRequestResponse @@ -14,8 +26,11 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.DraftDatabase +import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.database.MmsDatabase @@ -28,53 +43,37 @@ import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.DatabaseComponent import javax.inject.Inject -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine interface ConversationRepository { fun maybeGetRecipientForThreadId(threadId: Long): Recipient? + fun maybeGetBlindedRecipient(recipient: Recipient): Recipient? + fun changes(threadId: Long): Flow<Query> + fun recipientUpdateFlow(threadId: Long): Flow<Recipient?> fun saveDraft(threadId: Long, text: String) fun getDraft(threadId: Long): String? fun clearDrafts(threadId: Long) fun inviteContacts(threadId: Long, contacts: List<Recipient>) fun setBlocked(recipient: Recipient, blocked: Boolean) fun deleteLocally(recipient: Recipient, message: MessageRecord) + fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) fun setApproved(recipient: Recipient, isApproved: Boolean) - - suspend fun deleteForEveryone( - threadId: Long, - recipient: Recipient, - message: MessageRecord - ): ResultOf<Unit> - + suspend fun deleteForEveryone(threadId: Long, recipient: Recipient, message: MessageRecord): ResultOf<Unit> fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? - - suspend fun deleteMessageWithoutUnsendRequest( - threadId: Long, - messages: Set<MessageRecord> - ): ResultOf<Unit> - + suspend fun deleteMessageWithoutUnsendRequest(threadId: Long, messages: Set<MessageRecord>): ResultOf<Unit> suspend fun banUser(threadId: Long, recipient: Recipient): ResultOf<Unit> - suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): ResultOf<Unit> - suspend fun deleteThread(threadId: Long): ResultOf<Unit> - suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf<Unit> - suspend fun clearAllMessageRequests(block: Boolean): ResultOf<Unit> - suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit> - fun declineMessageRequest(threadId: Long) - fun hasReceived(threadId: Long): Boolean - } class DefaultConversationRepository @Inject constructor( + @ApplicationContext private val context: Context, private val textSecurePreferences: TextSecurePreferences, private val messageDataProvider: MessageDataProvider, private val threadDb: ThreadDatabase, @@ -87,13 +86,33 @@ class DefaultConversationRepository @Inject constructor( private val storage: Storage, private val lokiMessageDb: LokiMessageDatabase, private val sessionJobDb: SessionJobDatabase, - private val configFactory: ConfigFactory + private val configDb: ExpirationConfigurationDatabase, + private val configFactory: ConfigFactory, + private val contentResolver: ContentResolver, ) : ConversationRepository { override fun maybeGetRecipientForThreadId(threadId: Long): Recipient? { return threadDb.getRecipientForThreadId(threadId) } + override fun maybeGetBlindedRecipient(recipient: Recipient): Recipient? { + if (!recipient.isOpenGroupInboxRecipient) return null + return Recipient.from( + context, + Address.fromSerialized(GroupUtil.getDecodedOpenGroupInboxSessionId(recipient.address.serialize())), + false + ) + } + + override fun changes(threadId: Long): Flow<Query> = + contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId)) + + override fun recipientUpdateFlow(threadId: Long): Flow<Recipient?> { + return contentResolver.observeQuery(DatabaseContentProviders.Conversation.getUriForThread(threadId)).map { + maybeGetRecipientForThreadId(threadId) + } + } + override fun saveDraft(threadId: Long, text: String) { if (text.isEmpty()) return val drafts = DraftDatabase.Drafts() @@ -115,14 +134,20 @@ class DefaultConversationRepository @Inject constructor( for (contact in contacts) { val message = VisibleMessage() message.sentTimestamp = SnodeAPI.nowWithOffset - val openGroupInvitation = OpenGroupInvitation() - openGroupInvitation.name = openGroup.name - openGroupInvitation.url = openGroup.joinURL + val openGroupInvitation = OpenGroupInvitation().apply { + name = openGroup.name + url = openGroup.joinURL + } message.openGroupInvitation = openGroupInvitation + val expirationConfig = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(contact).let(storage::getExpirationConfiguration) + val expiresInMillis = expirationConfig?.expiryMode?.expiryMillis ?: 0 + val expireStartedAt = if (expirationConfig?.expiryMode is ExpiryMode.AfterSend) message.sentTimestamp!! else 0 val outgoingTextMessage = OutgoingTextMessage.fromOpenGroupInvitation( openGroupInvitation, contact, - message.sentTimestamp + message.sentTimestamp, + expiresInMillis, + expireStartedAt ) smsDb.insertMessageOutbox(-1, outgoingTextMessage, message.sentTimestamp!!, true) MessageSender.send(message, contact.address) @@ -143,6 +168,15 @@ class DefaultConversationRepository @Inject constructor( messageDataProvider.deleteMessage(message.id, !message.isMms) } + override fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) { + val threadId = messageRecord.threadId + val senderId = messageRecord.recipient.address.contactIdentifier() + val messageRecordsToRemoveFromLocalStorage = mmsSmsDb.getAllMessageRecordsFromSenderInThread(threadId, senderId) + for (message in messageRecordsToRemoveFromLocalStorage) { + messageDataProvider.deleteMessage(message.id, !message.isMms) + } + } + override fun setApproved(recipient: Recipient, isApproved: Boolean) { storage.setRecipientApproved(recipient, isApproved) } @@ -155,20 +189,40 @@ class DefaultConversationRepository @Inject constructor( buildUnsendRequest(recipient, message)?.let { unsendRequest -> MessageSender.send(unsendRequest, recipient.address) } + val openGroup = lokiThreadDb.getOpenGroupChat(threadId) if (openGroup != null) { - lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> + val serverId = lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server) .success { messageDataProvider.deleteMessage(message.id, !message.isMms) continuation.resume(ResultOf.Success(Unit)) }.fail { error -> + Log.w("TAG", "Call to OpenGroupApi.deleteForEveryone failed - attempting to resume..") continuation.resumeWithException(error) } } - } else { + + // If the server ID is null then this message is stuck in limbo (it has likely been + // deleted remotely but that deletion did not occur locally) - so we'll delete the + // message locally to clean up. + if (serverId == null) { + Log.w("ConversationRepository","Found community message without a server ID - deleting locally.") + + // Caution: The bool returned from `deleteMessage` is NOT "Was the message + // successfully deleted?" - it is "Was the thread itself also deleted because + // removing that message resulted in an empty thread?". + if (message.isMms) { + mmsDb.deleteMessage(message.id) + } else { + smsDb.deleteMessage(message.id) + } + } + } + else // If this thread is NOT in a Community + { messageDataProvider.deleteMessage(message.id, !message.isMms) - messageDataProvider.getServerHashForMessage(message.id)?.let { serverHash -> + messageDataProvider.getServerHashForMessage(message.id, message.isMms)?.let { serverHash -> var publicKey = recipient.address.serialize() if (recipient.isClosedGroupRecipient) { publicKey = GroupUtil.doubleDecodeGroupID(publicKey).toHexString() @@ -177,6 +231,7 @@ class DefaultConversationRepository @Inject constructor( .success { continuation.resume(ResultOf.Success(Unit)) }.fail { error -> + Log.w("ConversationRepository", "Call to SnodeAPI.deleteMessage failed - attempting to resume..") continuation.resumeWithException(error) } } @@ -184,17 +239,12 @@ class DefaultConversationRepository @Inject constructor( } override fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? { - if (recipient.isOpenGroupRecipient) return null - messageDataProvider.getServerHashForMessage(message.id) ?: return null - val unsendRequest = UnsendRequest() - if (message.isOutgoing) { - unsendRequest.author = textSecurePreferences.getLocalNumber() - } else { - unsendRequest.author = message.individualRecipient.address.contactIdentifier() - } - unsendRequest.timestamp = message.timestamp - - return unsendRequest + if (recipient.isCommunityRecipient) return null + messageDataProvider.getServerHashForMessage(message.id, message.isMms) ?: return null + return UnsendRequest( + author = message.takeUnless { it.isOutgoing }?.run { individualRecipient.address.contactIdentifier() } ?: textSecurePreferences.getLocalNumber(), + timestamp = message.timestamp + ) } override suspend fun deleteMessageWithoutUnsendRequest( @@ -209,7 +259,7 @@ class DefaultConversationRepository @Inject constructor( lokiMessageDb.getServerID(message.id, !message.isMms) ?: continue messageServerIDs[messageServerID] = message } - for ((messageServerID, message) in messageServerIDs) { + messageServerIDs.forEach { (messageServerID, message) -> OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server) .success { messageDataProvider.deleteMessage(message.id, !message.isMms) @@ -243,8 +293,10 @@ class DefaultConversationRepository @Inject constructor( override suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): ResultOf<Unit> = suspendCoroutine { continuation -> + // Note: This sessionId could be the blinded Id val sessionID = recipient.address.toString() val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! + OpenGroupApi.banAndDeleteAll(sessionID, openGroup.room, openGroup.server) .success { continuation.resume(ResultOf.Success(Unit)) @@ -270,9 +322,7 @@ class DefaultConversationRepository @Inject constructor( while (reader.next != null) { deleteMessageRequest(reader.current) val recipient = reader.current.recipient - if (block) { - setBlocked(recipient, true) - } + if (block) { setBlocked(recipient, true) } } } return ResultOf.Success(Unit) @@ -299,9 +349,7 @@ class DefaultConversationRepository @Inject constructor( val cursor = mmsSmsDb.getConversation(threadId, true) mmsSmsDb.readerFor(cursor).use { reader -> while (reader.next != null) { - if (!reader.current.isOutgoing) { - return true - } + if (!reader.current.isOutgoing) { return true } } } return false diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index ddfe85515c..f2adbf2349 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -4,12 +4,8 @@ import android.content.Context; import android.database.Cursor; import android.database.DatabaseUtils; import android.database.MergeCursor; -import android.text.TextUtils; - import androidx.annotation.NonNull; - import com.annimon.stream.Stream; - import org.session.libsession.messaging.contacts.Contact; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.GroupRecord; @@ -27,37 +23,25 @@ import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.search.model.MessageResult; import org.thoughtcrime.securesms.search.model.SearchResult; import org.thoughtcrime.securesms.util.Stopwatch; - import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.Executor; - import kotlin.Pair; -/** - * Manages data retrieval for search. - */ +// Class to manage data retrieval for search public class SearchRepository { - private static final String TAG = SearchRepository.class.getSimpleName(); private static final Set<Character> BANNED_CHARACTERS = new HashSet<>(); static { - // Several ranges of invalid ASCII characters - for (int i = 33; i <= 47; i++) { - BANNED_CHARACTERS.add((char) i); - } - for (int i = 58; i <= 64; i++) { - BANNED_CHARACTERS.add((char) i); - } - for (int i = 91; i <= 96; i++) { - BANNED_CHARACTERS.add((char) i); - } - for (int i = 123; i <= 126; i++) { - BANNED_CHARACTERS.add((char) i); - } + // Construct a list containing several ranges of invalid ASCII characters + // See: https://www.ascii-code.com/ + for (int i = 33; i <= 47; i++) { BANNED_CHARACTERS.add((char) i); } // !, ", #, $, %, &, ', (, ), *, +, ,, -, ., / + for (int i = 58; i <= 64; i++) { BANNED_CHARACTERS.add((char) i); } // :, ;, <, =, >, ?, @ + for (int i = 91; i <= 96; i++) { BANNED_CHARACTERS.add((char) i); } // [, \, ], ^, _, ` + for (int i = 123; i <= 126; i++) { BANNED_CHARACTERS.add((char) i); } // {, |, }, ~ } private final Context context; @@ -86,25 +70,25 @@ public class SearchRepository { } public void query(@NonNull String query, @NonNull Callback<SearchResult> callback) { - if (TextUtils.isEmpty(query)) { + // If the sanitized search is empty then abort without search + String cleanQuery = sanitizeQuery(query).trim(); + if (cleanQuery.isEmpty()) { callback.onResult(SearchResult.EMPTY); return; } executor.execute(() -> { Stopwatch timer = new Stopwatch("FtsQuery"); - - String cleanQuery = sanitizeQuery(query); timer.split("clean"); Pair<CursorList<Contact>, List<String>> contacts = queryContacts(cleanQuery); - timer.split("contacts"); + timer.split("Contacts"); CursorList<GroupRecord> conversations = queryConversations(cleanQuery, contacts.getSecond()); - timer.split("conversations"); + timer.split("Conversations"); CursorList<MessageResult> messages = queryMessages(cleanQuery); - timer.split("messages"); + timer.split("Messages"); timer.stop(TAG); @@ -113,22 +97,20 @@ public class SearchRepository { } public void query(@NonNull String query, long threadId, @NonNull Callback<CursorList<MessageResult>> callback) { - if (TextUtils.isEmpty(query)) { + // If the sanitized search query is empty then abort the search + String cleanQuery = sanitizeQuery(query).trim(); + if (cleanQuery.isEmpty()) { callback.onResult(CursorList.emptyList()); return; } executor.execute(() -> { - long startTime = System.currentTimeMillis(); - CursorList<MessageResult> messages = queryMessages(sanitizeQuery(query), threadId); - Log.d(TAG, "[ConversationQuery] " + (System.currentTimeMillis() - startTime) + " ms"); - + CursorList<MessageResult> messages = queryMessages(cleanQuery, threadId); callback.onResult(messages); }); } private Pair<CursorList<Contact>, List<String>> queryContacts(String query) { - Cursor contacts = contactDatabase.queryContactsByName(query); List<Address> contactList = new ArrayList<>(); List<String> contactStrings = new ArrayList<>(); @@ -155,11 +137,10 @@ public class SearchRepository { MergeCursor merged = new MergeCursor(new Cursor[]{addressThreads, individualRecipients}); return new Pair<>(new CursorList<>(merged, new ContactModelBuilder(contactDatabase, threadDatabase)), contactStrings); - } private CursorList<GroupRecord> queryConversations(@NonNull String query, List<String> matchingAddresses) { - List<String> numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query); + List<String> numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query); String localUserNumber = TextSecurePreferences.getLocalNumber(context); if (localUserNumber != null) { matchingAddresses.remove(localUserNumber); @@ -178,9 +159,7 @@ public class SearchRepository { membersGroupList.close(); } - Cursor conversations = threadDatabase.getFilteredConversationList(new ArrayList<>(addresses)); - return conversations != null ? new CursorList<>(conversations, new GroupModelBuilder(threadDatabase, groupDatabase)) : CursorList.emptyList(); } @@ -215,7 +194,7 @@ public class SearchRepository { out.append(' '); } } - + return out.toString(); } @@ -245,9 +224,7 @@ public class SearchRepository { private final Context context; - RecipientModelBuilder(@NonNull Context context) { - this.context = context; - } + RecipientModelBuilder(@NonNull Context context) { this.context = context; } @Override public Recipient build(@NonNull Cursor cursor) { @@ -290,9 +267,7 @@ public class SearchRepository { private final Context context; - MessageModelBuilder(@NonNull Context context) { - this.context = context; - } + MessageModelBuilder(@NonNull Context context) { this.context = context; } @Override public MessageResult build(@NonNull Cursor cursor) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java deleted file mode 100644 index 85d8c8f436..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java +++ /dev/null @@ -1,288 +0,0 @@ -package org.thoughtcrime.securesms.service; - -import android.content.Context; - -import org.jetbrains.annotations.NotNull; -import org.session.libsession.database.StorageProtocol; -import org.session.libsession.messaging.MessagingModuleConfiguration; -import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate; -import org.session.libsession.messaging.messages.signal.IncomingMediaMessage; -import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.GroupUtil; -import org.session.libsession.utilities.SSKEnvironment; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.messages.SignalServiceGroup; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.database.MmsDatabase; -import org.thoughtcrime.securesms.database.MmsSmsDatabase; -import org.thoughtcrime.securesms.database.SmsDatabase; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.mms.MmsException; - -import java.io.IOException; -import java.util.Comparator; -import java.util.TreeSet; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; - -public class ExpiringMessageManager implements SSKEnvironment.MessageExpirationManagerProtocol { - - private static final String TAG = ExpiringMessageManager.class.getSimpleName(); - - private final TreeSet<ExpiringMessageReference> expiringMessageReferences = new TreeSet<>(new ExpiringMessageComparator()); - private final Executor executor = Executors.newSingleThreadExecutor(); - - private final SmsDatabase smsDatabase; - private final MmsDatabase mmsDatabase; - private final MmsSmsDatabase mmsSmsDatabase; - private final Context context; - - public ExpiringMessageManager(Context context) { - this.context = context.getApplicationContext(); - this.smsDatabase = DatabaseComponent.get(context).smsDatabase(); - this.mmsDatabase = DatabaseComponent.get(context).mmsDatabase(); - this.mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); - - executor.execute(new LoadTask()); - executor.execute(new ProcessTask()); - } - - public void scheduleDeletion(long id, boolean mms, long expiresInMillis) { - scheduleDeletion(id, mms, System.currentTimeMillis(), expiresInMillis); - } - - public void scheduleDeletion(long id, boolean mms, long startedAtTimestamp, long expiresInMillis) { - long expiresAtMillis = startedAtTimestamp + expiresInMillis; - - synchronized (expiringMessageReferences) { - expiringMessageReferences.add(new ExpiringMessageReference(id, mms, expiresAtMillis)); - expiringMessageReferences.notifyAll(); - } - } - - public void checkSchedule() { - synchronized (expiringMessageReferences) { - expiringMessageReferences.notifyAll(); - } - } - - @Override - public void setExpirationTimer(@NotNull ExpirationTimerUpdate message) { - String userPublicKey = TextSecurePreferences.getLocalNumber(context); - String senderPublicKey = message.getSender(); - - // Notify the user - if (senderPublicKey == null || userPublicKey.equals(senderPublicKey)) { - // sender is self or a linked device - insertOutgoingExpirationTimerMessage(message); - } else { - insertIncomingExpirationTimerMessage(message); - } - - if (message.getId() != null) { - smsDatabase.deleteMessage(message.getId()); - } - } - - private void insertIncomingExpirationTimerMessage(ExpirationTimerUpdate message) { - - String senderPublicKey = message.getSender(); - Long sentTimestamp = message.getSentTimestamp(); - String groupId = message.getGroupPublicKey(); - int duration = message.getDuration(); - - Optional<SignalServiceGroup> groupInfo = Optional.absent(); - Address address = Address.fromSerialized(senderPublicKey); - Recipient recipient = Recipient.from(context, address, false); - - // if the sender is blocked, we don't display the update, except if it's in a closed group - if (recipient.isBlocked() && groupId == null) return; - - try { - if (groupId != null) { - String groupID = GroupUtil.doubleEncodeGroupID(groupId); - groupInfo = Optional.of(new SignalServiceGroup(GroupUtil.getDecodedGroupIDAsData(groupID), SignalServiceGroup.GroupType.SIGNAL)); - - Address groupAddress = Address.fromSerialized(groupID); - recipient = Recipient.from(context, groupAddress, false); - } - Long threadId = MessagingModuleConfiguration.getShared().getStorage().getThreadId(recipient); - if (threadId == null) { - return; - } - - IncomingMediaMessage mediaMessage = new IncomingMediaMessage(address, sentTimestamp, -1, - duration * 1000L, true, - false, - false, - false, - Optional.absent(), - groupInfo, - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); - //insert the timer update message - mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, true); - - //set the timer to the conversation - MessagingModuleConfiguration.getShared().getStorage().setExpirationTimer(recipient.getAddress().serialize(), duration); - - } catch (IOException | MmsException ioe) { - Log.e("Loki", "Failed to insert expiration update message."); - } - } - - private void insertOutgoingExpirationTimerMessage(ExpirationTimerUpdate message) { - - Long sentTimestamp = message.getSentTimestamp(); - String groupId = message.getGroupPublicKey(); - int duration = message.getDuration(); - - Address address; - - try { - if (groupId != null) { - address = Address.fromSerialized(GroupUtil.doubleEncodeGroupID(groupId)); - } else { - address = Address.fromSerialized((message.getSyncTarget() != null && !message.getSyncTarget().isEmpty()) ? message.getSyncTarget() : message.getRecipient()); - } - - Recipient recipient = Recipient.from(context, address, false); - StorageProtocol storage = MessagingModuleConfiguration.getShared().getStorage(); - message.setThreadID(storage.getOrCreateThreadIdFor(address)); - - OutgoingExpirationUpdateMessage timerUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, sentTimestamp, duration * 1000L, groupId); - mmsDatabase.insertSecureDecryptedMessageOutbox(timerUpdateMessage, message.getThreadID(), sentTimestamp, true); - //set the timer to the conversation - MessagingModuleConfiguration.getShared().getStorage().setExpirationTimer(recipient.getAddress().serialize(), duration); - } catch (MmsException | IOException ioe) { - Log.e("Loki", "Failed to insert expiration update message.", ioe); - } - } - - @Override - public void disableExpirationTimer(@NotNull ExpirationTimerUpdate message) { - setExpirationTimer(message); - } - - @Override - public void startAnyExpiration(long timestamp, @NotNull String author) { - MessageRecord messageRecord = mmsSmsDatabase.getMessageFor(timestamp, author); - if (messageRecord != null) { - boolean mms = messageRecord.isMms(); - Recipient recipient = messageRecord.getRecipient(); - if (recipient.getExpireMessages() <= 0) return; - if (mms) { - mmsDatabase.markExpireStarted(messageRecord.getId()); - } else { - smsDatabase.markExpireStarted(messageRecord.getId()); - } - scheduleDeletion(messageRecord.getId(), mms, recipient.getExpireMessages() * 1000); - } - } - - private class LoadTask implements Runnable { - - public void run() { - SmsDatabase.Reader smsReader = smsDatabase.readerFor(smsDatabase.getExpirationStartedMessages()); - MmsDatabase.Reader mmsReader = mmsDatabase.getExpireStartedMessages(); - - MessageRecord messageRecord; - - while ((messageRecord = smsReader.getNext()) != null) { - expiringMessageReferences.add(new ExpiringMessageReference(messageRecord.getId(), - messageRecord.isMms(), - messageRecord.getExpireStarted() + messageRecord.getExpiresIn())); - } - - while ((messageRecord = mmsReader.getNext()) != null) { - expiringMessageReferences.add(new ExpiringMessageReference(messageRecord.getId(), - messageRecord.isMms(), - messageRecord.getExpireStarted() + messageRecord.getExpiresIn())); - } - - smsReader.close(); - mmsReader.close(); - } - } - - @SuppressWarnings("InfiniteLoopStatement") - private class ProcessTask implements Runnable { - public void run() { - while (true) { - ExpiringMessageReference expiredMessage = null; - - synchronized (expiringMessageReferences) { - try { - while (expiringMessageReferences.isEmpty()) expiringMessageReferences.wait(); - - ExpiringMessageReference nextReference = expiringMessageReferences.first(); - long waitTime = nextReference.expiresAtMillis - System.currentTimeMillis(); - - if (waitTime > 0) { - ExpirationListener.setAlarm(context, waitTime); - expiringMessageReferences.wait(waitTime); - } else { - expiredMessage = nextReference; - expiringMessageReferences.remove(nextReference); - } - - } catch (InterruptedException e) { - Log.w(TAG, e); - } - } - - if (expiredMessage != null) { - if (expiredMessage.mms) mmsDatabase.deleteMessage(expiredMessage.id); - else smsDatabase.deleteMessage(expiredMessage.id); - } - } - } - } - - private static class ExpiringMessageReference { - private final long id; - private final boolean mms; - private final long expiresAtMillis; - - private ExpiringMessageReference(long id, boolean mms, long expiresAtMillis) { - this.id = id; - this.mms = mms; - this.expiresAtMillis = expiresAtMillis; - } - - @Override - public boolean equals(Object other) { - if (other == null) return false; - if (!(other instanceof ExpiringMessageReference)) return false; - - ExpiringMessageReference that = (ExpiringMessageReference)other; - return this.id == that.id && this.mms == that.mms && this.expiresAtMillis == that.expiresAtMillis; - } - - @Override - public int hashCode() { - return (int)this.id ^ (mms ? 1 : 0) ^ (int)expiresAtMillis; - } - } - - private static class ExpiringMessageComparator implements Comparator<ExpiringMessageReference> { - @Override - public int compare(ExpiringMessageReference lhs, ExpiringMessageReference rhs) { - if (lhs.expiresAtMillis < rhs.expiresAtMillis) return -1; - else if (lhs.expiresAtMillis > rhs.expiresAtMillis) return 1; - else if (lhs.id < rhs.id) return -1; - else if (lhs.id > rhs.id) return 1; - else if (!lhs.mms && rhs.mms) return -1; - else if (lhs.mms && !rhs.mms) return 1; - else return 0; - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt new file mode 100644 index 0000000000..2f6ad7fd8b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.kt @@ -0,0 +1,225 @@ +package org.thoughtcrime.securesms.service + +import android.content.Context +import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.ExpiryMode.AfterSend +import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.signal.IncomingMediaMessage +import org.session.libsession.messaging.messages.signal.OutgoingExpirationUpdateMessage +import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID +import org.session.libsession.utilities.GroupUtil.getDecodedGroupIDAsData +import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol +import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.messages.SignalServiceGroup +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get +import org.thoughtcrime.securesms.mms.MmsException +import java.io.IOException +import java.util.TreeSet +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +private val TAG = ExpiringMessageManager::class.java.simpleName +class ExpiringMessageManager(context: Context) : MessageExpirationManagerProtocol { + private val expiringMessageReferences = TreeSet<ExpiringMessageReference>() + private val executor: Executor = Executors.newSingleThreadExecutor() + private val smsDatabase: SmsDatabase + private val mmsDatabase: MmsDatabase + private val mmsSmsDatabase: MmsSmsDatabase + private val context: Context + + init { + this.context = context.applicationContext + smsDatabase = get(context).smsDatabase() + mmsDatabase = get(context).mmsDatabase() + mmsSmsDatabase = get(context).mmsSmsDatabase() + executor.execute(LoadTask()) + executor.execute(ProcessTask()) + } + + private fun getDatabase(mms: Boolean) = if (mms) mmsDatabase else smsDatabase + + fun scheduleDeletion(id: Long, mms: Boolean, startedAtTimestamp: Long, expiresInMillis: Long) { + if (startedAtTimestamp <= 0) return + + val expiresAtMillis = startedAtTimestamp + expiresInMillis + synchronized(expiringMessageReferences) { + expiringMessageReferences += ExpiringMessageReference(id, mms, expiresAtMillis) + (expiringMessageReferences as Object).notifyAll() + } + } + + fun checkSchedule() { + synchronized(expiringMessageReferences) { (expiringMessageReferences as Object).notifyAll() } + } + + private fun insertIncomingExpirationTimerMessage( + message: ExpirationTimerUpdate, + expireStartedAt: Long + ) { + val senderPublicKey = message.sender + val sentTimestamp = message.sentTimestamp + val groupId = message.groupPublicKey + val expiresInMillis = message.expiryMode.expiryMillis + var groupInfo = Optional.absent<SignalServiceGroup?>() + val address = fromSerialized(senderPublicKey!!) + var recipient = Recipient.from(context, address, false) + + // if the sender is blocked, we don't display the update, except if it's in a closed group + if (recipient.isBlocked && groupId == null) return + try { + if (groupId != null) { + val groupID = doubleEncodeGroupID(groupId) + groupInfo = Optional.of( + SignalServiceGroup( + getDecodedGroupIDAsData(groupID), + SignalServiceGroup.GroupType.SIGNAL + ) + ) + val groupAddress = fromSerialized(groupID) + recipient = Recipient.from(context, groupAddress, false) + } + val threadId = shared.storage.getThreadId(recipient) ?: return + val mediaMessage = IncomingMediaMessage( + address, sentTimestamp!!, -1, + expiresInMillis, expireStartedAt, true, + false, + false, + false, + Optional.absent(), + groupInfo, + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent() + ) + //insert the timer update message + mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) + } catch (ioe: IOException) { + Log.e("Loki", "Failed to insert expiration update message.") + } catch (ioe: MmsException) { + Log.e("Loki", "Failed to insert expiration update message.") + } + } + + private fun insertOutgoingExpirationTimerMessage( + message: ExpirationTimerUpdate, + expireStartedAt: Long + ) { + val sentTimestamp = message.sentTimestamp + val groupId = message.groupPublicKey + val duration = message.expiryMode.expiryMillis + try { + val serializedAddress = groupId?.let(::doubleEncodeGroupID) + ?: message.syncTarget?.takeIf { it.isNotEmpty() } + ?: message.recipient!! + val address = fromSerialized(serializedAddress) + val recipient = Recipient.from(context, address, false) + + message.threadID = shared.storage.getOrCreateThreadIdFor(address) + val timerUpdateMessage = OutgoingExpirationUpdateMessage( + recipient, + sentTimestamp!!, + duration, + expireStartedAt, + groupId + ) + mmsDatabase.insertSecureDecryptedMessageOutbox( + timerUpdateMessage, + message.threadID!!, + sentTimestamp, + true + ) + } catch (ioe: MmsException) { + Log.e("Loki", "Failed to insert expiration update message.", ioe) + } catch (ioe: IOException) { + Log.e("Loki", "Failed to insert expiration update message.", ioe) + } + } + + override fun insertExpirationTimerMessage(message: ExpirationTimerUpdate) { + val expiryMode: ExpiryMode = message.expiryMode + + val userPublicKey = getLocalNumber(context) + val senderPublicKey = message.sender + val sentTimestamp = message.sentTimestamp ?: 0 + val expireStartedAt = if ((expiryMode is AfterSend || message.isSenderSelf) && !message.isGroup) sentTimestamp else 0 + + // Notify the user + if (senderPublicKey == null || userPublicKey == senderPublicKey) { + // sender is self or a linked device + insertOutgoingExpirationTimerMessage(message, expireStartedAt) + } else { + insertIncomingExpirationTimerMessage(message, expireStartedAt) + } + + maybeStartExpiration(message) + } + + override fun startAnyExpiration(timestamp: Long, author: String, expireStartedAt: Long) { + mmsSmsDatabase.getMessageFor(timestamp, author)?.run { + getDatabase(isMms()).markExpireStarted(getId(), expireStartedAt) + scheduleDeletion(getId(), isMms(), expireStartedAt, expiresIn) + } ?: Log.e(TAG, "no message record!") + } + + private inner class LoadTask : Runnable { + override fun run() { + val smsReader = smsDatabase.readerFor(smsDatabase.getExpirationStartedMessages()) + val mmsReader = mmsDatabase.expireStartedMessages + + val smsMessages = smsReader.use { generateSequence { it.next }.toList() } + val mmsMessages = mmsReader.use { generateSequence { it.next }.toList() } + + (smsMessages + mmsMessages).forEach { messageRecord -> + expiringMessageReferences += ExpiringMessageReference( + messageRecord.getId(), + messageRecord.isMms, + messageRecord.expireStarted + messageRecord.expiresIn + ) + } + } + } + + private inner class ProcessTask : Runnable { + override fun run() { + while (true) { + synchronized(expiringMessageReferences) { + try { + while (expiringMessageReferences.isEmpty()) (expiringMessageReferences as Object).wait() + val nextReference = expiringMessageReferences.first() + val waitTime = nextReference.expiresAtMillis - nowWithOffset + if (waitTime > 0) { + ExpirationListener.setAlarm(context, waitTime) + (expiringMessageReferences as Object).wait(waitTime) + null + } else { + expiringMessageReferences -= nextReference + nextReference + } + } catch (e: InterruptedException) { + Log.w(TAG, e) + null + } + }?.run { getDatabase(mms).deleteMessage(id) } + } + } + } + + private data class ExpiringMessageReference( + val id: Long, + val mms: Boolean, + val expiresAtMillis: Long + ): Comparable<ExpiringMessageReference> { + override fun compareTo(other: ExpiringMessageReference) = compareValuesBy(this, other, { it.expiresAtMillis }, { it.id }, { it.mms }) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java index 402c0f6521..f919af7ad6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/KeyCachingService.java @@ -25,8 +25,10 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.content.pm.ServiceInfo; import android.os.AsyncTask; import android.os.Binder; +import android.os.Build; import android.os.IBinder; import android.os.SystemClock; @@ -250,7 +252,11 @@ public class KeyCachingService extends Service { builder.setContentIntent(buildLaunchIntent()); stopForeground(true); - startForeground(SERVICE_RUNNING_ID, builder.build()); + if (Build.VERSION.SDK_INT >= 34) { + startForeground(SERVICE_RUNNING_ID, builder.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE); + } else { + startForeground(SERVICE_RUNNING_ID, builder.build()); + } } private PendingIntent buildLockIntent() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt index 3ae3d30f01..cfe1f38f58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt @@ -182,9 +182,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { } fun broadcastWantsToAnswer(context: Context, wantsToAnswer: Boolean) { - val intent = Intent(ACTION_WANTS_TO_ANSWER) - .putExtra(EXTRA_WANTS_TO_ANSWER, wantsToAnswer) - + val intent = Intent(ACTION_WANTS_TO_ANSWER).putExtra(EXTRA_WANTS_TO_ANSWER, wantsToAnswer) LocalBroadcastManager.getInstance(context).sendBroadcast(intent) } @@ -506,9 +504,9 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { } private fun handleAnswerCall(intent: Intent) { - val recipient = callManager.recipient ?: return - val pending = callManager.pendingOffer ?: return - val callId = callManager.callId ?: return + val recipient = callManager.recipient ?: return Log.e(TAG, "No recipient to answer in handleAnswerCall") + val pending = callManager.pendingOffer ?: return Log.e(TAG, "No pending offer in handleAnswerCall") + val callId = callManager.callId ?: return Log.e(TAG, "No callId in handleAnswerCall") val timestamp = callManager.pendingOfferTime if (callManager.currentConnectionState != CallState.RemoteRing) { @@ -526,9 +524,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { insertMissedCall(recipient, true) terminate() } - if (didHangup) { - return - } + if (didHangup) { return } } callManager.postConnectionEvent(Event.SendAnswer) { @@ -686,7 +682,6 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { private fun registerPowerButtonReceiver() { if (powerButtonReceiver == null) { powerButtonReceiver = PowerButtonReceiver() - registerReceiver(powerButtonReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF)) } } @@ -719,7 +714,6 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { } } - private fun handleCheckTimeout(intent: Intent) { val callId = callManager.callId ?: return val callState = callManager.currentConnectionState @@ -746,9 +740,9 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { } if (!CallNotificationBuilder.areNotificationsEnabled(this) && type == TYPE_INCOMING_PRE_OFFER) { - // start an intent for the fullscreen + // Start an intent for the fullscreen call activity val foregroundIntent = Intent(this, WebRtcCallActivity::class.java) - .setFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_BROUGHT_TO_FRONT) + .setFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_BROUGHT_TO_FRONT or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) .setAction(WebRtcCallActivity.ACTION_FULL_SCREEN_INTENT) startActivity(foregroundIntent) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/TelephonyServiceState.java b/app/src/main/java/org/thoughtcrime/securesms/sms/TelephonyServiceState.java deleted file mode 100644 index b6cb7e5e2a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/TelephonyServiceState.java +++ /dev/null @@ -1,92 +0,0 @@ -package org.thoughtcrime.securesms.sms; - -import android.content.Context; -import android.os.Looper; -import android.telephony.PhoneStateListener; -import android.telephony.ServiceState; -import android.telephony.TelephonyManager; - -public class TelephonyServiceState { - - public boolean isConnected(Context context) { - ListenThread listenThread = new ListenThread(context); - listenThread.start(); - - return listenThread.get(); - } - - private static class ListenThread extends Thread { - - private final Context context; - - private boolean complete; - private boolean result; - - public ListenThread(Context context) { - this.context = context.getApplicationContext(); - } - - @Override - public void run() { - Looper looper = initializeLooper(); - ListenCallback callback = new ListenCallback(looper); - - TelephonyManager telephonyManager = (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE); - telephonyManager.listen(callback, PhoneStateListener.LISTEN_SERVICE_STATE); - - Looper.loop(); - - telephonyManager.listen(callback, PhoneStateListener.LISTEN_NONE); - - set(callback.isConnected()); - } - - private Looper initializeLooper() { - Looper looper = Looper.myLooper(); - - if (looper == null) { - Looper.prepare(); - } - - return Looper.myLooper(); - } - - public synchronized boolean get() { - while (!complete) { - try { - wait(); - } catch (InterruptedException e) { - throw new AssertionError(e); - } - } - - return result; - } - - private synchronized void set(boolean result) { - this.result = result; - this.complete = true; - notifyAll(); - } - } - - private static class ListenCallback extends PhoneStateListener { - - private final Looper looper; - private volatile boolean connected; - - public ListenCallback(Looper looper) { - this.looper = looper; - } - - @Override - public void onServiceStateChanged(ServiceState serviceState) { - this.connected = (serviceState.getState() == ServiceState.STATE_IN_SERVICE); - looper.quit(); - } - - public boolean isConnected() { - return connected; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt new file mode 100644 index 0000000000..0b7b6d6b4c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Carousel.kt @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import network.loki.messenger.R + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun BoxScope.HorizontalPagerIndicator(pagerState: PagerState) { + if (pagerState.pageCount >= 2) Card( + shape = RoundedCornerShape(50.dp), + backgroundColor = Color.Black.copy(alpha = 0.4f), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(8.dp) + ) { + Box(modifier = Modifier.padding(8.dp)) { + com.google.accompanist.pager.HorizontalPagerIndicator( + pagerState = pagerState, + pageCount = pagerState.pageCount, + activeColor = Color.White, + inactiveColor = classicDarkColors[5]) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RowScope.CarouselPrevButton(pagerState: PagerState) { + CarouselButton(pagerState, pagerState.canScrollBackward, R.drawable.ic_prev, -1) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RowScope.CarouselNextButton(pagerState: PagerState) { + CarouselButton(pagerState, pagerState.canScrollForward, R.drawable.ic_next, 1) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RowScope.CarouselButton( + pagerState: PagerState, + enabled: Boolean, + @DrawableRes id: Int, + delta: Int +) { + if (pagerState.pageCount <= 1) Spacer(modifier = Modifier.width(32.dp)) + else { + val animationScope = rememberCoroutineScope() + IconButton( + modifier = Modifier + .width(40.dp) + .align(Alignment.CenterVertically), + enabled = enabled, + onClick = { animationScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + delta) } }) { + Icon( + painter = painterResource(id = id), + contentDescription = null, + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 1724bde8a6..6c223a45f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -1,42 +1,93 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ButtonColors +import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card import androidx.compose.material.Colors import androidx.compose.material.Icon -import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.RadioButton import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import com.google.accompanist.pager.HorizontalPagerIndicator -import kotlinx.coroutines.launch -import network.loki.messenger.R import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.runIf import org.thoughtcrime.securesms.components.ProfilePictureView +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCard +import kotlin.math.min + +interface Callbacks<in T> { + fun onSetClick(): Any? + fun setValue(value: T) +} + +object NoOpCallbacks: Callbacks<Any> { + override fun onSetClick() {} + override fun setValue(value: Any) {} +} + +data class RadioOption<T>( + val value: T, + val title: GetString, + val subtitle: GetString? = null, + val contentDescription: GetString = title, + val selected: Boolean = false, + val enabled: Boolean = true, +) + +@Composable +fun <T> OptionsCard(card: OptionsCard<T>, callbacks: Callbacks<T>) { + Text(text = card.title()) + CellNoMargin { + LazyColumn( + modifier = Modifier.heightIn(max = 5000.dp) + ) { + itemsIndexed(card.options) { i, it -> + if (i != 0) Divider() + TitledRadioButton(it) { callbacks.setValue(it.value) } + } + } + } +} + @Composable fun ItemButton( @@ -95,66 +146,109 @@ fun CellWithPaddingAndMargin( } } +@Composable +fun <T> TitledRadioButton(option: RadioOption<T>, onClick: () -> Unit) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .runIf(option.enabled) { clickable { if (!option.selected) onClick() } } + .heightIn(min = 60.dp) + .padding(horizontal = 32.dp) + .contentDescription(option.contentDescription) + ) { + Column(modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically)) { + Column { + Text( + text = option.title(), + fontSize = 16.sp, + modifier = Modifier.alpha(if (option.enabled) 1f else 0.5f) + ) + option.subtitle?.let { + Text( + text = it(), + fontSize = 11.sp, + modifier = Modifier.alpha(if (option.enabled) 1f else 0.5f) + ) + } + } + } + RadioButton( + selected = option.selected, + onClick = null, + enabled = option.enabled, + modifier = Modifier + .height(26.dp) + .align(Alignment.CenterVertically) + ) + } +} + +@Composable +fun Modifier.contentDescription(text: GetString?): Modifier { + val context = LocalContext.current + return text?.let { semantics { contentDescription = it(context) } } ?: this +} + +@Composable +fun OutlineButton(text: GetString, contentDescription: GetString? = text, modifier: Modifier = Modifier, onClick: () -> Unit) { + OutlinedButton( + modifier = modifier.size(108.dp, 34.dp) + .contentDescription(contentDescription), + onClick = onClick, + border = BorderStroke(1.dp, LocalExtraColors.current.prominentButtonColor), + shape = RoundedCornerShape(50), // = 50% percent + colors = ButtonDefaults.outlinedButtonColors( + contentColor = LocalExtraColors.current.prominentButtonColor, + backgroundColor = MaterialTheme.colors.background + ) + ){ + Text(text = text()) + } +} + private val Colors.cellColor: Color @Composable get() = LocalExtraColors.current.settingsBackground -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun BoxScope.HorizontalPagerIndicator(pagerState: PagerState) { - if (pagerState.pageCount >= 2) Card( - shape = RoundedCornerShape(50.dp), - backgroundColor = Color.Black.copy(alpha = 0.4f), - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(8.dp) - ) { - Box(modifier = Modifier.padding(8.dp)) { - HorizontalPagerIndicator( - pagerState = pagerState, - pageCount = pagerState.pageCount, - activeColor = Color.White, - inactiveColor = classicDarkColors[5]) - } - } -} +fun Modifier.fadingEdges( + scrollState: ScrollState, + topEdgeHeight: Dp = 0.dp, + bottomEdgeHeight: Dp = 20.dp +): Modifier = this.then( + Modifier + // adding layer fixes issue with blending gradient and content + .graphicsLayer { alpha = 0.99F } + .drawWithContent { + drawContent() -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun RowScope.CarouselPrevButton(pagerState: PagerState) { - CarouselButton(pagerState, pagerState.canScrollBackward, R.drawable.ic_prev, -1) -} + val topColors = listOf(Color.Transparent, Color.Black) + val topStartY = scrollState.value.toFloat() + val topGradientHeight = min(topEdgeHeight.toPx(), topStartY) + if (topGradientHeight > 0f) drawRect( + brush = Brush.verticalGradient( + colors = topColors, + startY = topStartY, + endY = topStartY + topGradientHeight + ), + blendMode = BlendMode.DstIn + ) -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun RowScope.CarouselNextButton(pagerState: PagerState) { - CarouselButton(pagerState, pagerState.canScrollForward, R.drawable.ic_next, 1) -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun RowScope.CarouselButton( - pagerState: PagerState, - enabled: Boolean, - @DrawableRes id: Int, - delta: Int -) { - if (pagerState.pageCount <= 1) Spacer(modifier = Modifier.width(32.dp)) - else { - val animationScope = rememberCoroutineScope() - IconButton( - modifier = Modifier - .width(40.dp) - .align(Alignment.CenterVertically), - enabled = enabled, - onClick = { animationScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + delta) } }) { - Icon( - painter = painterResource(id = id), - contentDescription = "", + val bottomColors = listOf(Color.Black, Color.Transparent) + val bottomEndY = size.height - scrollState.maxValue + scrollState.value + val bottomGradientHeight = + min(bottomEdgeHeight.toPx(), scrollState.maxValue.toFloat() - scrollState.value) + if (bottomGradientHeight > 0f) drawRect( + brush = Brush.verticalGradient( + colors = bottomColors, + startY = bottomEndY - bottomGradientHeight, + endY = bottomEndY + ), + blendMode = BlendMode.DstIn ) } - } -} +) @Composable fun Divider() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt index 44ff4a42d8..e472209005 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt @@ -1,28 +1,55 @@ package org.thoughtcrime.securesms.ui +import android.content.Context import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import org.session.libsession.utilities.ExpirationUtil +import kotlin.time.Duration /** * Compatibility class to allow ViewModels to use strings and string resources interchangeably. */ sealed class GetString { + + @Composable + operator fun invoke() = string() + operator fun invoke(context: Context) = string(context) + @Composable abstract fun string(): String + + abstract fun string(context: Context): String data class FromString(val string: String): GetString() { @Composable override fun string(): String = string + override fun string(context: Context): String = string } data class FromResId(@StringRes val resId: Int): GetString() { @Composable override fun string(): String = stringResource(resId) + override fun string(context: Context): String = context.getString(resId) + } + data class FromFun(val function: (Context) -> String): GetString() { + @Composable + override fun string(): String = function(LocalContext.current) + override fun string(context: Context): String = function(context) + } + data class FromMap<T>(val value: T, val function: (Context, T) -> String): GetString() { + @Composable + override fun string(): String = function(LocalContext.current, value) + + override fun string(context: Context): String = function(context, value) } } fun GetString(@StringRes resId: Int) = GetString.FromResId(resId) fun GetString(string: String) = GetString.FromString(string) +fun GetString(function: (Context) -> String) = GetString.FromFun(function) +fun <T> GetString(value: T, function: (Context, T) -> String) = GetString.FromMap(value, function) +fun GetString(duration: Duration) = GetString.FromMap(duration, ExpirationUtil::getExpirationDisplayValue) /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt index 64bbd21d8d..3fa861fb71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt @@ -22,6 +22,7 @@ val LocalExtraColors = staticCompositionLocalOf<ExtraColors> { error("No Custom data class ExtraColors( val settingsBackground: Color, + val prominentButtonColor: Color ) /** @@ -34,6 +35,7 @@ fun AppTheme( val extraColors = LocalContext.current.run { ExtraColors( settingsBackground = getColorFromTheme(R.attr.colorSettingsBackground), + prominentButtonColor = getColorFromTheme(R.attr.prominentButtonColor), ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt index 297014d86c..9d10cfdab5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt @@ -193,7 +193,7 @@ object ConfigurationMessageUtilities { while (current != null) { val recipient = current.recipient val contact = when { - recipient.isOpenGroupRecipient -> { + recipient.isCommunityRecipient -> { val openGroup = storage.getOpenGroup(current.threadId) ?: continue val (base, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: continue convoConfig.getOrConstructCommunity(base, room, pubKey) @@ -279,7 +279,7 @@ object ConfigurationMessageUtilities { @JvmField val DELETE_INACTIVE_ONE_TO_ONES: String = """ - DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.OPEN_GROUP_INBOX_PREFIX}%'; + DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.COMMUNITY_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.COMMUNITY_INBOX_PREFIX}%'; """.trimIndent() } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt new file mode 100644 index 0000000000..f228eb57a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.util + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import android.os.Handler +import android.os.Looper +import androidx.annotation.CheckResult +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * Observe changes to a content Uri. This function will emit the Uri whenever the content or + * its descendants change, according to the parameter [notifyForDescendants]. + */ +@CheckResult +fun ContentResolver.observeChanges(uri: Uri, notifyForDescendants: Boolean = false): Flow<Uri> { + return callbackFlow { + val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + trySend(uri) + } + } + + registerContentObserver(uri, notifyForDescendants, observer) + awaitClose { + unregisterContentObserver(observer) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ContextProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ContextProvider.kt deleted file mode 100644 index 4bc8e104dd..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ContextProvider.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.app.Activity -import android.content.Context -import android.content.Intent -import androidx.fragment.app.Fragment - -/** - * A simplified version of [android.content.ContextWrapper], - * but properly supports [startActivityForResult] for the implementations. - */ -interface ContextProvider { - fun getContext(): Context - fun startActivityForResult(intent: Intent, requestCode: Int) -} - -class ActivityContextProvider(private val activity: Activity): ContextProvider { - - override fun getContext(): Context { - return activity - } - - override fun startActivityForResult(intent: Intent, requestCode: Int) { - activity.startActivityForResult(intent, requestCode) - } -} - -class FragmentContextProvider(private val fragment: Fragment): ContextProvider { - - override fun getContext(): Context { - return fragment.requireContext() - } - - override fun startActivityForResult(intent: Intent, requestCode: Int) { - fragment.startActivityForResult(intent, requestCode) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt index a38c93831e..9124765763 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt @@ -37,3 +37,8 @@ val RecyclerView.isScrolledToBottom: Boolean get() = computeVerticalScrollOffset().coerceAtLeast(0) + computeVerticalScrollExtent() + toPx(50, resources) >= computeVerticalScrollRange() + +val RecyclerView.isScrolledToWithin30dpOfBottom: Boolean + get() = computeVerticalScrollOffset().coerceAtLeast(0) + + computeVerticalScrollExtent() + + toPx(30, resources) >= computeVerticalScrollRange() \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt index 479a54fafa..bc76b80f2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt @@ -55,7 +55,7 @@ class IP2Country private constructor(private val context: Context) { public fun configureIfNeeded(context: Context) { if (isInitialized) { return; } - shared = IP2Country(context) + shared = IP2Country(context.applicationContext) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LongClickMovementMethod.java b/app/src/main/java/org/thoughtcrime/securesms/util/LongClickMovementMethod.java deleted file mode 100644 index 82077f474d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/LongClickMovementMethod.java +++ /dev/null @@ -1,104 +0,0 @@ -package org.thoughtcrime.securesms.util; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.graphics.Color; -import androidx.core.content.ContextCompat; -import android.text.Layout; -import android.text.Selection; -import android.text.Spannable; -import android.text.method.LinkMovementMethod; -import android.view.GestureDetector; -import android.view.MotionEvent; -import android.view.View; -import android.widget.TextView; - -import network.loki.messenger.R; - -public class LongClickMovementMethod extends LinkMovementMethod { - @SuppressLint("StaticFieldLeak") - private static LongClickMovementMethod sInstance; - - private final GestureDetector gestureDetector; - private View widget; - private LongClickCopySpan currentSpan; - - private LongClickMovementMethod(final Context context) { - gestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { - @Override - public void onLongPress(MotionEvent e) { - if (currentSpan != null && widget != null) { - currentSpan.onLongClick(widget); - widget = null; - currentSpan = null; - } - } - - @Override - public boolean onSingleTapUp(MotionEvent e) { - if (currentSpan != null && widget != null) { - currentSpan.onClick(widget); - widget = null; - currentSpan = null; - } - return true; - } - }); - } - - @Override - public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) { - int action = event.getAction(); - - if (action == MotionEvent.ACTION_UP || - action == MotionEvent.ACTION_DOWN) { - int x = (int) event.getX(); - int y = (int) event.getY(); - - x -= widget.getTotalPaddingLeft(); - y -= widget.getTotalPaddingTop(); - - x += widget.getScrollX(); - y += widget.getScrollY(); - - Layout layout = widget.getLayout(); - int line = layout.getLineForVertical(y); - int off = layout.getOffsetForHorizontal(line, x); - - LongClickCopySpan longClickCopySpan[] = buffer.getSpans(off, off, LongClickCopySpan.class); - if (longClickCopySpan.length != 0) { - LongClickCopySpan aSingleSpan = longClickCopySpan[0]; - if (action == MotionEvent.ACTION_DOWN) { - Selection.setSelection(buffer, buffer.getSpanStart(aSingleSpan), - buffer.getSpanEnd(aSingleSpan)); - aSingleSpan.setHighlighted(true, - ContextCompat.getColor(widget.getContext(), R.color.touch_highlight)); - } else { - Selection.removeSelection(buffer); - aSingleSpan.setHighlighted(false, Color.TRANSPARENT); - } - - this.currentSpan = aSingleSpan; - this.widget = widget; - return gestureDetector.onTouchEvent(event); - } - } else if (action == MotionEvent.ACTION_CANCEL) { - // Remove Selections. - LongClickCopySpan[] spans = buffer.getSpans(Selection.getSelectionStart(buffer), - Selection.getSelectionEnd(buffer), LongClickCopySpan.class); - for (LongClickCopySpan aSpan : spans) { - aSpan.setHighlighted(false, Color.TRANSPARENT); - } - Selection.removeSelection(buffer); - return gestureDetector.onTouchEvent(event); - } - return super.onTouchEvent(widget, buffer, event); - } - - public static LongClickMovementMethod getInstance(Context context) { - if (sInstance == null) { - sInstance = new LongClickMovementMethod(context.getApplicationContext()); - } - return sInstance; - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt index 06fda29306..4d00da8f96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt @@ -131,6 +131,7 @@ object MockDataGenerator { .joinToString(), Optional.absent(), 0, + 0, false, -1, false @@ -148,6 +149,7 @@ object MockDataGenerator { .map { wordContent.random(dmThreadRandomGenerator.asKotlinRandom()) } .joinToString(), 0, + 0, -1, (timestampNow - (index * 5000)) ), @@ -232,14 +234,12 @@ object MockDataGenerator { // Add the group to the user's set of public keys to poll for and store the key pair val encryptionKeyPair = Curve.generateKeyPair() storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, randomGroupPublicKey, System.currentTimeMillis()) - storage.setExpirationTimer(groupId, 0) - storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair) + storage.createInitialConfigGroup(randomGroupPublicKey, groupName, GroupUtil.createConfigMemberMap(members, setOf(adminUserId)), System.currentTimeMillis(), encryptionKeyPair, 0) // Add the group created message if (userSessionId == adminUserId) { storage.insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), threadId, (timestampNow - (numMessages * 5000))) - } - else { + } else { storage.insertIncomingInfoMessage(context, adminUserId, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), (timestampNow - (numMessages * 5000))) } @@ -261,6 +261,7 @@ object MockDataGenerator { .joinToString(), Optional.absent(), 0, + 0, false, -1, false @@ -278,6 +279,7 @@ object MockDataGenerator { .map { wordContent.random(cgThreadRandomGenerator.asKotlinRandom()) } .joinToString(), 0, + 0, -1, (timestampNow - (index * 5000)) ), @@ -386,6 +388,7 @@ object MockDataGenerator { .joinToString(), Optional.absent(), 0, + 0, false, -1, false @@ -402,6 +405,7 @@ object MockDataGenerator { .map { wordContent.random(ogThreadRandomGenerator.asKotlinRandom()) } .joinToString(), 0, + 0, -1, (timestampNow - (index * 5000)) ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt index 8b219849a0..8df2a7cd2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt @@ -66,6 +66,7 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int this.contextReference = WeakReference(context) } + @Deprecated("Deprecated in Java") override fun doInBackground(vararg attachments: Attachment?): Pair<Int, String?> { if (attachments.isEmpty()) { throw IllegalArgumentException("Must pass in at least one attachment") @@ -227,6 +228,7 @@ class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int return File(fileName).name } + @Deprecated("Deprecated in Java") override fun onPostExecute(result: Pair<Int, String?>) { super.onPostExecute(result) val context = contextReference.get() ?: return diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt index ea1a90d19a..e5de4c36d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ScanQRCodeWrapperFragment.kt @@ -29,6 +29,7 @@ class ScanQRCodeWrapperFragment : Fragment(), ScanQRCodePlaceholderFragmentDeleg } } + @Deprecated("Deprecated in Java") override fun setUserVisibleHint(isVisibleToUser: Boolean) { super.setUserVisibleHint(isVisibleToUser) enabled = isVisibleToUser diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt index b15d82a33e..3984f38b51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt @@ -14,7 +14,7 @@ fun ConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Bool return getOneToOne(recipient.address.serialize())?.unread == true } else if (recipient.isClosedGroupRecipient) { return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true - } else if (recipient.isOpenGroupRecipient) { + } else if (recipient.isCommunityRecipient) { val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false return getCommunity(openGroup.server, openGroup.room)?.unread == true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SimpleTextWatcher.java b/app/src/main/java/org/thoughtcrime/securesms/util/SimpleTextWatcher.java similarity index 90% rename from app/src/main/java/org/thoughtcrime/securesms/contactshare/SimpleTextWatcher.java rename to app/src/main/java/org/thoughtcrime/securesms/util/SimpleTextWatcher.java index b2448b8f85..512748bae7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SimpleTextWatcher.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SimpleTextWatcher.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.contactshare; +package org.thoughtcrime.securesms.util; import android.text.Editable; import android.text.TextWatcher; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java b/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java index cac53899fb..d92fc7546d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Stopwatch.java @@ -37,12 +37,10 @@ public class Stopwatch { for (int i = 1; i < splits.size(); i++) { out.append(splits.get(i).label).append(": "); out.append(splits.get(i).time - splits.get(i - 1).time); - out.append(" "); + out.append("ms "); } - - out.append("total: ").append(splits.get(splits.size() - 1).time - startTime); + out.append("total: ").append(splits.get(splits.size() - 1).time - startTime).append("ms."); } - Log.d(tag, out.toString()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt index dfd4ffe419..c0477825fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -9,13 +9,17 @@ import android.graphics.Bitmap import android.graphics.PointF import android.graphics.Rect import android.util.Size +import android.util.TypedValue import android.view.View import androidx.annotation.ColorInt import androidx.annotation.DimenRes import network.loki.messenger.R import org.session.libsession.utilities.getColorFromAttr import android.view.inputmethod.InputMethodManager +import androidx.annotation.AttrRes +import androidx.annotation.ColorRes import androidx.core.graphics.applyCanvas +import org.session.libsignal.utilities.Log import kotlin.math.roundToInt fun View.contains(point: PointF): Boolean { @@ -32,6 +36,24 @@ val View.hitRect: Rect @ColorInt fun Context.getAccentColor() = getColorFromAttr(R.attr.colorAccent) +// Method to grab the appropriate attribute for a message colour. +// Note: This is an attribute, NOT a resource Id - see `getColorResourceIdFromAttr` for that. +@AttrRes +fun getMessageTextColourAttr(messageIsOutgoing: Boolean): Int { + return if (messageIsOutgoing) R.attr.message_sent_text_color else R.attr.message_received_text_color +} + +// Method to get an actual R.id.<SOME_COLOUR> resource Id from an attribute such as R.attr.message_sent_text_color etc. +@ColorRes +fun getColorResourceIdFromAttr(context: Context, attr: Int): Int { + val outTypedValue = TypedValue() + val successfullyFoundAttribute = context.theme.resolveAttribute(attr, outTypedValue, true) + if (successfullyFoundAttribute) { return outTypedValue.resourceId } + + Log.w("ViewUtils", "Could not find colour attribute $attr in theme - using grey as a safe fallback") + return R.color.gray50 +} + fun View.animateSizeChange(@DimenRes startSizeID: Int, @DimenRes endSizeID: Int, animationDuration: Long = 250) { val startSize = resources.getDimension(startSizeID) val endSize = resources.getDimension(endSizeID) @@ -70,7 +92,6 @@ fun View.hideKeyboard() { imm.hideSoftInputFromWindow(this.windowToken, 0) } - fun View.drawToBitmap(config: Bitmap.Config = Bitmap.Config.ARGB_8888, longestWidth: Int = 2000): Bitmap { val size = Size(measuredWidth, measuredHeight).coerceAtMost(longestWidth) val scale = size.width / measuredWidth.toFloat() diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/WakeLockUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/WakeLockUtil.java deleted file mode 100644 index bba19f1fc2..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/WakeLockUtil.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.thoughtcrime.securesms.util; - -import android.content.Context; -import android.os.PowerManager; -import android.os.PowerManager.WakeLock; -import androidx.annotation.NonNull; - -import org.session.libsession.utilities.ServiceUtil; -import org.session.libsignal.utilities.Log; - -public class WakeLockUtil { - - private static final String TAG = WakeLockUtil.class.getSimpleName(); - - /** - * @param tag will be prefixed with "signal:" if it does not already start with it. - */ - public static WakeLock acquire(@NonNull Context context, int lockType, long timeout, @NonNull String tag) { - tag = prefixTag(tag); - try { - PowerManager powerManager = ServiceUtil.getPowerManager(context); - WakeLock wakeLock = powerManager.newWakeLock(lockType, tag); - - wakeLock.acquire(timeout); - Log.d(TAG, "Acquired wakelock with tag: " + tag); - - return wakeLock; - } catch (Exception e) { - Log.w(TAG, "Failed to acquire wakelock with tag: " + tag, e); - return null; - } - } - - /** - * @param tag will be prefixed with "signal:" if it does not already start with it. - */ - public static void release(@NonNull WakeLock wakeLock, @NonNull String tag) { - tag = prefixTag(tag); - try { - if (wakeLock.isHeld()) { - wakeLock.release(); - Log.d(TAG, "Released wakelock with tag: " + tag); - } else { - Log.d(TAG, "Wakelock wasn't held at time of release: " + tag); - } - } catch (Exception e) { - Log.w(TAG, "Failed to release wakelock with tag: " + tag, e); - } - } - - private static String prefixTag(@NonNull String tag) { - return tag.startsWith("signal:") ? tag : "signal:" + tag; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioEvent.kt deleted file mode 100644 index b01edfb492..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioEvent.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.thoughtcrime.securesms.webrtc - -enum class AudioEvent { - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index 894de9de64..ff5e481895 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -16,6 +16,7 @@ import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.bind import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.calls.CallMessageType +import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.snode.SnodeAPI @@ -25,6 +26,7 @@ import org.session.libsession.utilities.Util import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ICE_CANDIDATES import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioDeviceUpdate import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioEnabled import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.RecipientUpdate @@ -57,8 +59,12 @@ import java.util.ArrayDeque import java.util.UUID import org.thoughtcrime.securesms.webrtc.data.State as CallState -class CallManager(context: Context, audioManager: AudioManagerCompat, private val storage: StorageProtocol): PeerConnection.Observer, - SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer { +class CallManager( + private val context: Context, + audioManager: AudioManagerCompat, + private val storage: StorageProtocol +): PeerConnection.Observer, + SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer { sealed class StateEvent { data class AudioEnabled(val isEnabled: Boolean): StateEvent() @@ -293,17 +299,17 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va while (pendingOutgoingIceUpdates.isNotEmpty()) { currentPendings.add(pendingOutgoingIceUpdates.pop()) } - val sdps = currentPendings.map { it.sdp } - val sdpMLineIndexes = currentPendings.map { it.sdpMLineIndex } - val sdpMids = currentPendings.map { it.sdpMid } - MessageSender.sendNonDurably(CallMessage( - ICE_CANDIDATES, - sdps = sdps, - sdpMLineIndexes = sdpMLineIndexes, - sdpMids = sdpMids, - currentCallId - ), currentRecipient.address, isSyncMessage = currentRecipient.isLocalNumber) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(expectedRecipient) + CallMessage( + ICE_CANDIDATES, + sdps = currentPendings.map(IceCandidate::sdp), + sdpMLineIndexes = currentPendings.map(IceCandidate::sdpMLineIndex), + sdpMids = currentPendings.map(IceCandidate::sdpMid), + currentCallId + ) + .applyExpiryMode(thread) + .also { MessageSender.sendNonDurably(it, currentRecipient.address, isSyncMessage = currentRecipient.isLocalNumber) } } } } @@ -402,6 +408,10 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va override fun onCameraSwitchCompleted(newCameraState: CameraState) { localCameraState = newCameraState + + // If the camera we've switched to is the front one then mirror it to match what someone + // would see when looking in the mirror rather than the left<-->right flipped version. + localRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT) } fun onPreOffer(callId: UUID, recipient: Recipient, onSuccess: () -> Unit) { @@ -419,6 +429,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va fun onNewOffer(offer: String, callId: UUID, recipient: Recipient): Promise<Unit, Exception> { if (callId != this.callId) return Promise.ofFail(NullPointerException("No callId")) if (recipient != this.recipient) return Promise.ofFail(NullPointerException("No recipient")) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) val connection = peerConnection ?: return Promise.ofFail(NullPointerException("No peer connection wrapper")) @@ -431,11 +442,9 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true")) }) connection.setLocalDescription(answer) - pendingIncomingIceUpdates.toList().forEach { update -> - connection.addIceCandidate(update) - } + pendingIncomingIceUpdates.toList().forEach(connection::addIceCandidate) pendingIncomingIceUpdates.clear() - val answerMessage = CallMessage.answer(answer.description, callId) + val answerMessage = CallMessage.answer(answer.description, callId).applyExpiryMode(thread) Log.i("Loki", "Posting new answer") MessageSender.sendNonDurably(answerMessage, recipient.address, isSyncMessage = recipient.isLocalNumber) } else { @@ -479,13 +488,14 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va connection.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, offer)) val answer = connection.createAnswer(MediaConstraints()) connection.setLocalDescription(answer) - val answerMessage = CallMessage.answer(answer.description, callId) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + val answerMessage = CallMessage.answer(answer.description, callId).applyExpiryMode(thread) val userAddress = storage.getUserPublicKey() ?: return Promise.ofFail(NullPointerException("No user public key")) MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress), isSyncMessage = true) val sendAnswerMessage = MessageSender.sendNonDurably(CallMessage.answer( answer.description, callId - ), recipient.address, isSyncMessage = recipient.isLocalNumber) + ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_INCOMING, false) @@ -533,15 +543,16 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va connection.setLocalDescription(offer) Log.d("Loki", "Sending pre-offer") + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) return MessageSender.sendNonDurably(CallMessage.preOffer( callId - ), recipient.address, isSyncMessage = recipient.isLocalNumber).bind { + ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber).bind { Log.d("Loki", "Sent pre-offer") Log.d("Loki", "Sending offer") MessageSender.sendNonDurably(CallMessage.offer( offer.description, callId - ), recipient.address, isSyncMessage = recipient.isLocalNumber).success { + ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber).success { Log.d("Loki", "Sent offer") }.fail { Log.e("Loki", "Failed to send offer", it) @@ -555,8 +566,9 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va val recipient = recipient ?: return val userAddress = storage.getUserPublicKey() ?: return stateProcessor.processEvent(Event.DeclineCall) { - MessageSender.sendNonDurably(CallMessage.endCall(callId), Address.fromSerialized(userAddress), isSyncMessage = true) - MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address, isSyncMessage = recipient.isLocalNumber) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + MessageSender.sendNonDurably(CallMessage.endCall(callId).applyExpiryMode(thread), Address.fromSerialized(userAddress), isSyncMessage = true) + MessageSender.sendNonDurably(CallMessage.endCall(callId).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_MISSED) } } @@ -575,7 +587,9 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va val buffer = DataChannel.Buffer(ByteBuffer.wrap(HANGUP_JSON.toString().encodeToByteArray()), false) channel.send(buffer) } - MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address, isSyncMessage = recipient.isLocalNumber) + + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + MessageSender.sendNonDurably(CallMessage.endCall(callId).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) } } @@ -629,7 +643,11 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va peerConnection?.let { connection -> connection.flipCamera() localCameraState = connection.getCameraState() - localRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT) + + // Note: We cannot set the mirrored state of the localRenderer here because + // localCameraState.activeDirection is still PENDING (not FRONT or BACK) until the flip + // completes and we hit Camera.onCameraSwitchDone (followed by PeerConnectionWrapper.onCameraSwitchCompleted + // and CallManager.onCameraSwitchCompleted). } } @@ -725,8 +743,8 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true")) }) connection.setLocalDescription(offer) - - MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId), recipient.address, isSyncMessage = recipient.isLocalNumber) + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + MessageSender.sendNonDurably(CallMessage.offer(offer.description, callId).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt index 26d8fc223d..f78b93d6b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt @@ -50,7 +50,7 @@ class PeerConnectionWrapper(private val context: Context, private fun initPeerConnection() { val random = SecureRandom().asKotlinRandom() - val iceServers = listOf("freyr","fenrir","frigg","angus","hereford","holstein", "brahman").shuffled(random).take(2).map { sub -> + val iceServers = listOf("freyr","angus","hereford","holstein", "brahman").shuffled(random).take(2).map { sub -> PeerConnection.IceServer.builder("turn:$sub.getsession.org") .setUsername("session202111") .setPassword("053c268164bc7bd7") @@ -326,8 +326,6 @@ class PeerConnectionWrapper(private val context: Context, } override fun onCameraSwitchCompleted(newCameraState: CameraState) { - // mirror rotation offset - rotationVideoSink.mirrored = newCameraState.activeDirection == CameraState.Direction.FRONT cameraEventListener.onCameraSwitchCompleted(newCameraState) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt index 09db0022d8..dbbbffc3e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt @@ -19,6 +19,7 @@ class HangUpRtcOnPstnCallAnsweredListener(private val hangupListener: ()->Unit): private val TAG = Log.tag(HangUpRtcOnPstnCallAnsweredListener::class.java) } + @Deprecated("Deprecated in Java") override fun onCallStateChanged(state: Int, phoneNumber: String?) { super.onCallStateChanged(state, phoneNumber) if (state == TelephonyManager.CALL_STATE_OFFHOOK) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/Camera.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/Camera.kt index 421c144199..b76b168977 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/Camera.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/video/Camera.kt @@ -54,7 +54,7 @@ class Camera(context: Context, Log.w(TAG, "Tried to flip camera without capturer or less than 2 cameras") return } - activeDirection = PENDING + activeDirection = PENDING // Note: The activeDirection will be PENDING until `onCameraSwitchDone` capturer.switchCamera(this) } diff --git a/app/src/main/res/drawable/call_message_background.xml b/app/src/main/res/drawable/call_message_background.xml new file mode 100644 index 0000000000..7713909167 --- /dev/null +++ b/app/src/main/res/drawable/call_message_background.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape + xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + + <solid android:color="?message_received_background_color" /> + + <corners android:radius="@dimen/message_corner_radius" /> +</shape> \ No newline at end of file diff --git a/app/src/main/res/drawable/cross.xml b/app/src/main/res/drawable/cross.xml new file mode 100644 index 0000000000..5b090de2b3 --- /dev/null +++ b/app/src/main/res/drawable/cross.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="100dp" + android:height="100dp" + android:viewportWidth="100" + android:viewportHeight="100"> + + <path + android:pathData="M0,0 L100,100 M0,100 L100,0" + android:strokeWidth="1" + android:strokeColor="@android:color/white" /> +</vector> \ No newline at end of file diff --git a/app/src/main/res/drawable/destructive_dialog_text_button_background.xml b/app/src/main/res/drawable/destructive_dialog_text_button_background.xml index f3e13c8000..3ba98c4992 100644 --- a/app/src/main/res/drawable/destructive_dialog_text_button_background.xml +++ b/app/src/main/res/drawable/destructive_dialog_text_button_background.xml @@ -4,7 +4,6 @@ <item android:id="@android:id/mask"> <shape android:shape="rectangle"> <solid android:color="?android:textColorPrimary"/> - <corners android:radius="@dimen/medium_button_corner_radius" /> </shape> </item> </ripple> diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24dp.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24dp.xml new file mode 100644 index 0000000000..f3dfdd0bdb --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24dp.xml @@ -0,0 +1,5 @@ +<vector android:height="24dp" android:tint="#000000" + android:viewportHeight="24" android:viewportWidth="24" + android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="@android:color/white" android:pathData="M15.41,16.59L10.83,12l4.58,-4.59L14,6l-6,6 6,6 1.41,-1.41z"/> +</vector> diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24dp.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24dp.xml new file mode 100644 index 0000000000..19ec5a5eb8 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24dp.xml @@ -0,0 +1,5 @@ +<vector android:height="24dp" android:tint="#000000" + android:viewportHeight="24" android:viewportWidth="24" + android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="@android:color/white" android:pathData="M8.59,16.59L13.17,12 8.59,7.41 10,6l6,6 -6,6 -1.41,-1.41z"/> +</vector> diff --git a/app/src/main/res/drawable/ic_baseline_timer_off_24.xml b/app/src/main/res/drawable/ic_baseline_timer_off_24.xml deleted file mode 100644 index 9216da7aff..0000000000 --- a/app/src/main/res/drawable/ic_baseline_timer_off_24.xml +++ /dev/null @@ -1,10 +0,0 @@ -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - <path - android:fillColor="@android:color/white" - android:pathData="M19.04,4.55l-1.42,1.42C16.07,4.74 14.12,4 12,4c-1.83,0 -3.53,0.55 -4.95,1.48l1.46,1.46C9.53,6.35 10.73,6 12,6c3.87,0 7,3.13 7,7 0,1.27 -0.35,2.47 -0.94,3.49l1.45,1.45C20.45,16.53 21,14.83 21,13c0,-2.12 -0.74,-4.07 -1.97,-5.61l1.42,-1.42 -1.41,-1.42zM15,1L9,1v2h6L15,1zM11,9.44l2,2L13,8h-2v1.44zM3.02,4L1.75,5.27 4.5,8.03C3.55,9.45 3,11.16 3,13c0,4.97 4.02,9 9,9 1.84,0 3.55,-0.55 4.98,-1.5l2.5,2.5 1.27,-1.27 -7.71,-7.71L3.02,4zM12,20c-3.87,0 -7,-3.13 -7,-7 0,-1.28 0.35,-2.48 0.95,-3.52l9.56,9.56c-1.03,0.61 -2.23,0.96 -3.51,0.96z"/> -</vector> diff --git a/app/src/main/res/drawable/ic_incoming_call.xml b/app/src/main/res/drawable/ic_incoming_call.xml index da1a78fe50..f9149818c5 100644 --- a/app/src/main/res/drawable/ic_incoming_call.xml +++ b/app/src/main/res/drawable/ic_incoming_call.xml @@ -1,6 +1,6 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="20dp" - android:height="20dp" + android:width="16dp" + android:height="16dp" android:viewportWidth="20" android:viewportHeight="20"> <path diff --git a/app/src/main/res/drawable/ic_missed_call.xml b/app/src/main/res/drawable/ic_missed_call.xml index 4d3aee2a09..e63537737b 100644 --- a/app/src/main/res/drawable/ic_missed_call.xml +++ b/app/src/main/res/drawable/ic_missed_call.xml @@ -1,6 +1,6 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="20dp" - android:height="20dp" + android:width="16dp" + android:height="16dp" android:viewportWidth="20" android:viewportHeight="20"> <path diff --git a/app/src/main/res/drawable/ic_outgoing_call.xml b/app/src/main/res/drawable/ic_outgoing_call.xml index ad27f18d52..26024999ee 100644 --- a/app/src/main/res/drawable/ic_outgoing_call.xml +++ b/app/src/main/res/drawable/ic_outgoing_call.xml @@ -1,6 +1,6 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="20dp" - android:height="20dp" + android:width="16dp" + android:height="16dp" android:viewportWidth="20" android:viewportHeight="20"> <path diff --git a/app/src/main/res/drawable/ic_outline_settings_24.xml b/app/src/main/res/drawable/ic_outline_settings_24.xml new file mode 100644 index 0000000000..c939e9ce8b --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_settings_24.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98 0,-0.34 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.09,-0.16 -0.26,-0.25 -0.44,-0.25 -0.06,0 -0.12,0.01 -0.17,0.03l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.06,-0.02 -0.12,-0.03 -0.18,-0.03 -0.17,0 -0.34,0.09 -0.43,0.25l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98 0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.09,0.16 0.26,0.25 0.44,0.25 0.06,0 0.12,-0.01 0.17,-0.03l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.06,0.02 0.12,0.03 0.18,0.03 0.17,0 0.34,-0.09 0.43,-0.25l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64l-2.11,-1.65zM17.45,11.27c0.04,0.31 0.05,0.52 0.05,0.73 0,0.21 -0.02,0.43 -0.05,0.73l-0.14,1.13 0.89,0.7 1.08,0.84 -0.7,1.21 -1.27,-0.51 -1.04,-0.42 -0.9,0.68c-0.43,0.32 -0.84,0.56 -1.25,0.73l-1.06,0.43 -0.16,1.13 -0.2,1.35h-1.4l-0.19,-1.35 -0.16,-1.13 -1.06,-0.43c-0.43,-0.18 -0.83,-0.41 -1.23,-0.71l-0.91,-0.7 -1.06,0.43 -1.27,0.51 -0.7,-1.21 1.08,-0.84 0.89,-0.7 -0.14,-1.13c-0.03,-0.31 -0.05,-0.54 -0.05,-0.74s0.02,-0.43 0.05,-0.73l0.14,-1.13 -0.89,-0.7 -1.08,-0.84 0.7,-1.21 1.27,0.51 1.04,0.42 0.9,-0.68c0.43,-0.32 0.84,-0.56 1.25,-0.73l1.06,-0.43 0.16,-1.13 0.2,-1.35h1.39l0.19,1.35 0.16,1.13 1.06,0.43c0.43,0.18 0.83,0.41 1.23,0.71l0.91,0.7 1.06,-0.43 1.27,-0.51 0.7,1.21 -1.07,0.85 -0.89,0.7 0.14,1.13zM12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" /> +</vector> diff --git a/app/src/main/res/drawable/tab_indicator_dot.xml b/app/src/main/res/drawable/tab_indicator_dot.xml new file mode 100644 index 0000000000..72f57dc8bc --- /dev/null +++ b/app/src/main/res/drawable/tab_indicator_dot.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_selected="true"> + <shape + android:shape="ring" + android:innerRadius="0dp" + android:thickness="2dp" + android:useLevel="false"> + <solid android:color="?android:textColorPrimary"/> + </shape> + </item> + <item> + <shape + android:shape="ring" + android:innerRadius="0dp" + android:thickness="2dp" + android:useLevel="false"> + <solid android:color="?android:textColorTertiary"/> + </shape> + </item> +</selector> \ No newline at end of file diff --git a/app/src/main/res/drawable/unimportant_dialog_text_button_background.xml b/app/src/main/res/drawable/unimportant_dialog_text_button_background.xml index f3e13c8000..3ba98c4992 100644 --- a/app/src/main/res/drawable/unimportant_dialog_text_button_background.xml +++ b/app/src/main/res/drawable/unimportant_dialog_text_button_background.xml @@ -4,7 +4,6 @@ <item android:id="@android:id/mask"> <shape android:shape="rectangle"> <solid android:color="?android:textColorPrimary"/> - <corners android:radius="@dimen/medium_button_corner_radius" /> </shape> </item> </ripple> diff --git a/app/src/main/res/layout-sw400dp/activity_display_name.xml b/app/src/main/res/layout-sw400dp/activity_display_name.xml index 4d4ff30406..d62faca064 100644 --- a/app/src/main/res/layout-sw400dp/activity_display_name.xml +++ b/app/src/main/res/layout-sw400dp/activity_display_name.xml @@ -43,6 +43,8 @@ android:paddingBottom="0dp" android:gravity="center_vertical" android:inputType="textCapWords" + android:maxLength="@integer/max_user_nickname_length_chars" + android:maxLines="1" android:hint="@string/activity_display_name_edit_text_hint" /> <View diff --git a/app/src/main/res/layout-sw400dp/activity_recovery_phrase_restore.xml b/app/src/main/res/layout-sw400dp/activity_recovery_phrase_restore.xml deleted file mode 100644 index 88d90f5aaf..0000000000 --- a/app/src/main/res/layout-sw400dp/activity_recovery_phrase_restore.xml +++ /dev/null @@ -1,78 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<LinearLayout - xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical"> - - <View - android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1"/> - - <TextView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginLeft="@dimen/very_large_spacing" - android:layout_marginRight="@dimen/very_large_spacing" - android:textSize="@dimen/very_large_font_size" - android:textStyle="bold" - android:textColor="?android:textColorPrimary" - android:text="@string/activity_restore_title" /> - - <TextView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginLeft="@dimen/very_large_spacing" - android:layout_marginTop="7dp" - android:layout_marginRight="@dimen/very_large_spacing" - android:textSize="@dimen/medium_font_size" - android:textColor="?android:textColorPrimary" - android:text="@string/activity_restore_explanation" /> - - <EditText - style="@style/SessionEditText" - android:id="@+id/mnemonicEditText" - android:contentDescription="@string/AccessibilityId_enter_your_recovery_phrase" - android:layout_width="match_parent" - android:layout_height="64dp" - android:layout_marginLeft="@dimen/very_large_spacing" - android:layout_marginTop="12dp" - android:layout_marginRight="@dimen/very_large_spacing" - android:paddingTop="0dp" - android:paddingBottom="0dp" - android:gravity="center_vertical" - android:inputType="textMultiLine" - android:maxLines="3" - android:hint="@string/activity_restore_seed_edit_text_hint" /> - - <View - android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1"/> - - <Button - style="@style/Widget.Session.Button.Common.ProminentFilled" - android:id="@+id/restoreButton" - android:contentDescription="@string/AccessibilityId_continue" - android:layout_width="match_parent" - android:layout_height="@dimen/medium_button_height" - android:layout_marginLeft="@dimen/massive_spacing" - android:layout_marginRight="@dimen/massive_spacing" - android:text="@string/continue_2" /> - - <TextView - android:id="@+id/termsTextView" - android:layout_width="match_parent" - android:layout_height="@dimen/onboarding_button_bottom_offset" - android:layout_marginLeft="@dimen/massive_spacing" - android:layout_marginRight="@dimen/massive_spacing" - android:gravity="center" - android:textColor="?android:textColorTertiary" - android:textColorLink="?colorAccent" - android:textSize="@dimen/very_small_font_size" - android:text="By using this service, you agree to our Terms of Service and Privacy Policy" - tools:ignore="HardcodedText" /> <!-- Intentionally not yet translated --> - -</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 5afde1e296..6fe0c4db60 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -16,11 +16,17 @@ android:background="?colorPrimary" app:contentInsetStart="0dp"> - <include android:id="@+id/toolbarContent" - layout="@layout/activity_conversation_v2_action_bar" /> + <org.thoughtcrime.securesms.conversation.ConversationActionBarView + android:id="@+id/toolbarContent" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> </androidx.appcompat.widget.Toolbar> + <!-- + Add this to the below recycler view if you need to debug activity `adjustResize` issues: + android:background="@drawable/cross" + --> <org.thoughtcrime.securesms.conversation.v2.ConversationRecyclerView android:focusable="false" android:id="@+id/conversationRecyclerView" @@ -29,6 +35,7 @@ android:layout_above="@+id/typingIndicatorViewContainer" android:layout_below="@id/toolbar" /> + <org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer android:focusable="false" android:id="@+id/typingIndicatorViewContainer" @@ -216,6 +223,29 @@ </RelativeLayout> + <RelativeLayout + android:id="@+id/outdatedBanner" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_below="@+id/blockedBanner" + android:background="@color/outdated_client_banner_background_color" + android:visibility="gone" + tools:visibility="visible"> + + <TextView + android:id="@+id/outdatedBannerTextView" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:layout_centerInParent="true" + android:layout_marginVertical="@dimen/very_small_spacing" + android:layout_marginHorizontal="@dimen/medium_spacing" + android:textColor="@color/black" + android:textSize="@dimen/tiny_font_size" + tools:text="This user's client is outdated, things may not work as expected" /> + + </RelativeLayout> + <TextView android:padding="@dimen/medium_spacing" android:textSize="@dimen/small_font_size" diff --git a/app/src/main/res/layout/activity_conversation_v2_action_bar.xml b/app/src/main/res/layout/activity_conversation_v2_action_bar.xml deleted file mode 100644 index 7322bb7f00..0000000000 --- a/app/src/main/res/layout/activity_conversation_v2_action_bar.xml +++ /dev/null @@ -1,68 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<LinearLayout - xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="?attr/actionBarSize" - xmlns:tools="http://schemas.android.com/tools" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:orientation="horizontal" - android:gravity="center_vertical"> - - <org.thoughtcrime.securesms.components.ProfilePictureView - android:id="@+id/profilePictureView" - android:layout_width="@dimen/medium_profile_picture_size" - android:layout_height="@dimen/medium_profile_picture_size" /> - - <LinearLayout - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:orientation="vertical"> - - <TextView - android:id="@+id/conversationTitleView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="12dp" - android:contentDescription="@string/AccessibilityId_username" - tools:text="@tools:sample/full_names" - android:textColor="?android:textColorPrimary" - android:textStyle="bold" - android:textSize="@dimen/very_large_font_size" - android:maxLines="1" - android:ellipsize="end" /> - - <LinearLayout - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="12dp" - android:orientation="horizontal" - android:gravity="center_vertical"> - - <ImageView - android:id="@+id/muteIconImageView" - android:layout_width="14dp" - android:layout_height="14dp" - android:layout_marginEnd="4dp" - android:layout_gravity="center" - android:src="@drawable/ic_outline_notifications_off_24" - app:tint="?android:textColorPrimary" - android:alpha="0.6" - android:visibility="gone" - tools:visibility="visible"/> - - <TextView - android:id="@+id/conversationSubtitleView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="Muted" - android:textColor="?android:textColorPrimary" - android:alpha="0.6" - android:textSize="@dimen/very_small_font_size" - android:maxLines="1" - android:ellipsize="end" /> - - </LinearLayout> - - </LinearLayout> - -</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_disappearing_messages.xml b/app/src/main/res/layout/activity_disappearing_messages.xml new file mode 100644 index 0000000000..8101297732 --- /dev/null +++ b/app/src/main/res/layout/activity_disappearing_messages.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <androidx.appcompat.widget.Toolbar + android:id="@+id/toolbar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?colorPrimary" + app:contentInsetStart="0dp" + app:subtitle="@string/activity_disappearing_messages_subtitle" + app:subtitleTextAppearance="@style/TextAppearance.Session.ToolbarSubtitle" + app:title="@string/activity_disappearing_messages_title" /> + + <androidx.compose.ui.platform.ComposeView + android:id="@+id/container" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + +</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_display_name.xml b/app/src/main/res/layout/activity_display_name.xml index f8c4d634e6..54351693b0 100644 --- a/app/src/main/res/layout/activity_display_name.xml +++ b/app/src/main/res/layout/activity_display_name.xml @@ -43,6 +43,8 @@ android:paddingBottom="0dp" android:gravity="center_vertical" android:inputType="textCapWords" + android:maxLength="@integer/max_user_nickname_length_chars" + android:maxLines="1" android:hint="@string/activity_display_name_edit_text_hint" /> <View diff --git a/app/src/main/res/layout/activity_edit_closed_group.xml b/app/src/main/res/layout/activity_edit_closed_group.xml index e8a892f184..78def9e972 100644 --- a/app/src/main/res/layout/activity_edit_closed_group.xml +++ b/app/src/main/res/layout/activity_edit_closed_group.xml @@ -32,6 +32,7 @@ android:id="@+id/btnCancelGroupNameEdit" android:layout_width="24dp" android:layout_height="24dp" + android:layout_marginLeft="@dimen/medium_spacing" android:contentDescription="@string/AccessibilityId_cancel_name_change" android:src="@drawable/ic_baseline_clear_24"/> @@ -49,6 +50,7 @@ android:inputType="text" android:singleLine="true" android:imeOptions="actionDone" + android:maxLength="@integer/max_group_and_community_name_length_chars" android:contentDescription="@string/AccessibilityId_group_name" android:hint="@string/activity_edit_closed_group_edit_text_hint" /> @@ -57,6 +59,7 @@ android:layout_width="24dp" android:layout_height="24dp" android:layout_gravity="center" + android:layout_marginRight="@dimen/medium_spacing" android:contentDescription="@string/AccessibilityId_accept_name_change" android:src="@drawable/ic_baseline_done_24"/> diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index bf308612c5..124a44b374 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -110,7 +110,7 @@ android:layout_gravity="center" android:textColor="?message_sent_text_color" android:background="?colorAccent" - android:textSize="9sp" + android:textSize="11sp" android:paddingVertical="4dp" android:paddingHorizontal="64dp" android:gravity="center" diff --git a/app/src/main/res/layout/activity_recovery_phrase_restore.xml b/app/src/main/res/layout/activity_recovery_phrase_restore.xml deleted file mode 100644 index 5f2012e9c1..0000000000 --- a/app/src/main/res/layout/activity_recovery_phrase_restore.xml +++ /dev/null @@ -1,78 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<LinearLayout - xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical"> - - <View - android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1"/> - - <TextView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginLeft="@dimen/very_large_spacing" - android:layout_marginRight="@dimen/very_large_spacing" - android:textSize="@dimen/large_font_size" - android:textStyle="bold" - android:textColor="?android:textColorPrimary" - android:text="@string/activity_restore_title" /> - - <TextView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginLeft="@dimen/very_large_spacing" - android:layout_marginTop="4dp" - android:layout_marginRight="@dimen/very_large_spacing" - android:textSize="@dimen/small_font_size" - android:textColor="?android:textColorPrimary" - android:text="@string/activity_restore_explanation" /> - - <EditText - android:id="@+id/mnemonicEditText" - style="@style/SmallSessionEditText" - android:contentDescription="@string/AccessibilityId_enter_your_recovery_phrase" - android:layout_width="match_parent" - android:layout_height="64dp" - android:layout_marginLeft="@dimen/very_large_spacing" - android:layout_marginTop="10dp" - android:layout_marginRight="@dimen/very_large_spacing" - android:gravity="center_vertical" - android:hint="@string/activity_restore_seed_edit_text_hint" - android:inputType="textMultiLine" - android:maxLines="3" - android:paddingTop="0dp" - android:paddingBottom="0dp" /> - - <View - android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1"/> - - <Button - style="@style/Widget.Session.Button.Common.ProminentFilled" - android:contentDescription="@string/AccessibilityId_continue" - android:id="@+id/restoreButton" - android:layout_width="match_parent" - android:layout_height="@dimen/medium_button_height" - android:layout_marginLeft="@dimen/massive_spacing" - android:layout_marginRight="@dimen/massive_spacing" - android:text="@string/continue_2" /> - - <TextView - android:id="@+id/termsTextView" - android:layout_width="match_parent" - android:layout_height="@dimen/onboarding_button_bottom_offset" - android:layout_marginLeft="@dimen/massive_spacing" - android:layout_marginRight="@dimen/massive_spacing" - android:gravity="center" - android:textColorLink="?android:textColorPrimary" - android:textSize="@dimen/very_small_font_size" - android:textColor="?android:textColorPrimary" - android:text="By using this service, you agree to our Terms of Service and Privacy Policy" - tools:ignore="HardcodedText" /> <!-- Intentionally not yet translated --> - -</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 44df7e82ef..d84f183b5c 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -47,7 +47,11 @@ android:paddingTop="12dp" android:paddingBottom="12dp" android:visibility="invisible" - android:hint="@string/activity_settings_display_name_edit_text_hint" /> + android:hint="@string/activity_settings_display_name_edit_text_hint" + android:imeOptions="actionDone" + android:inputType="textCapWords" + android:maxLength="@integer/max_user_nickname_length_chars" + android:maxLines="1" /> <TextView android:id="@+id/btnGroupNameDisplay" @@ -55,8 +59,10 @@ android:layout_height="wrap_content" android:layout_centerInParent="true" android:contentDescription="@string/AccessibilityId_username" + android:gravity="center" android:textColor="?android:textColorPrimary" android:textSize="@dimen/very_large_font_size" + android:maxLength="@integer/max_user_nickname_length_chars" android:textStyle="bold" /> </RelativeLayout> diff --git a/app/src/main/res/layout/context_menu_item.xml b/app/src/main/res/layout/context_menu_item.xml index 04354f5b10..83d43d82d7 100644 --- a/app/src/main/res/layout/context_menu_item.xml +++ b/app/src/main/res/layout/context_menu_item.xml @@ -18,13 +18,28 @@ android:layout_height="24dp" tools:src="@drawable/ic_message"/> - <TextView - android:id="@+id/context_menu_item_title" - android:textAppearance="@style/TextAppearance.AppCompat.Body1" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_weight="1" + <LinearLayout + android:layout_width="match_parent" android:layout_marginStart="16dp" - tools:text="Archive" /> + android:layout_height="wrap_content" + android:orientation="vertical"> + + <TextView + android:id="@+id/context_menu_item_title" + android:textAppearance="@style/TextAppearance.AppCompat.Body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + tools:text="Archive" /> + + <TextView + android:id="@+id/context_menu_item_subtitle" + android:textSize="@dimen/tiny_font_size" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + tools:text="subtitle" /> + + </LinearLayout> </LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_item_footer.xml b/app/src/main/res/layout/conversation_item_footer.xml deleted file mode 100644 index 3aefb39444..0000000000 --- a/app/src/main/res/layout/conversation_item_footer.xml +++ /dev/null @@ -1,57 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<merge xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:orientation="horizontal" - tools:parentTag="org.thoughtcrime.securesms.components.ConversationItemFooter"> - - <LinearLayout - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_weight="1" - android:layout_marginEnd="6dp" - android:orientation="horizontal" - android:gravity="left|start|center_vertical"> - - <TextView - android:id="@+id/footer_date" - android:autoLink="none" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:textSize="@dimen/very_small_font_size" - android:linksClickable="false" - style="@style/Signal.Text.Caption.MessageSent" - android:textColor="?conversation_item_sent_text_secondary_color" - android:textAllCaps="true" - tools:text="30 mins"/> - - <org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView - android:id="@+id/footer_expiration_timer" - android:layout_gravity="center_vertical|end" - android:layout_marginStart="6dp" - android:layout_width="12dp" - android:layout_height="12dp" - android:contentDescription="@string/AccessibilityId_timer_icon" - android:visibility="gone" - tools:visibility="visible"/> - - </LinearLayout> - - <ImageView - android:id="@+id/footer_insecure_indicator" - android:layout_width="12dp" - android:layout_height="11dp" - android:src="@drawable/ic_unlocked_white_18dp" - android:visibility="gone" - android:layout_gravity="center_vertical|end" - android:contentDescription="@string/conversation_item__secure_message_description" - tools:visibility="visible"/> - - <org.thoughtcrime.securesms.components.DeliveryStatusView - android:id="@+id/footer_delivery_status" - android:layout_width="20dp" - android:layout_height="wrap_content" - android:layout_gravity="center_vertical" /> - -</merge> diff --git a/app/src/main/res/layout/dialog_clear_all_data.xml b/app/src/main/res/layout/dialog_clear_all_data.xml index db95647dad..4ef5c40e8e 100644 --- a/app/src/main/res/layout/dialog_clear_all_data.xml +++ b/app/src/main/res/layout/dialog_clear_all_data.xml @@ -6,8 +6,7 @@ android:layout_height="match_parent" android:gravity="center_horizontal" android:orientation="vertical" - android:elevation="4dp" - android:padding="@dimen/medium_spacing"> + android:elevation="4dp"> <TextView android:layout_width="wrap_content" @@ -21,6 +20,8 @@ android:id="@+id/dialogDescriptionText" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_marginStart="20dp" + android:layout_marginEnd="20dp" android:layout_marginTop="@dimen/large_spacing" android:text="@string/dialog_clear_all_data_message" android:textAlignment="center" @@ -46,16 +47,15 @@ style="@style/Widget.Session.Button.Dialog.DestructiveText" android:id="@+id/clearAllDataButton" android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" + android:layout_height="@dimen/dialog_button_height" android:layout_weight="1" - android:layout_marginStart="@dimen/medium_spacing" android:text="@string/dialog_clear_all_data_clear" /> <Button style="@style/Widget.Session.Button.Dialog.UnimportantText" android:id="@+id/cancelButton" android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" + android:layout_height="@dimen/dialog_button_height" android:layout_weight="1" android:text="@string/cancel" /> diff --git a/app/src/main/res/layout/dialog_send_seed.xml b/app/src/main/res/layout/dialog_send_seed.xml index 1aed6f0428..725c9c4d83 100644 --- a/app/src/main/res/layout/dialog_send_seed.xml +++ b/app/src/main/res/layout/dialog_send_seed.xml @@ -38,7 +38,7 @@ style="@style/Widget.Session.Button.Dialog.UnimportantText" android:id="@+id/cancelButton" android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" + android:layout_height="@dimen/dialog_button_height" android:layout_weight="1" android:text="@string/cancel" /> @@ -46,7 +46,7 @@ style="@style/Widget.Session.Button.Dialog.DestructiveText" android:id="@+id/sendSeedButton" android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" + android:layout_height="@dimen/dialog_button_height" android:layout_weight="1" android:layout_marginStart="@dimen/medium_spacing" android:text="@string/dialog_send_seed_send_button_title" /> diff --git a/app/src/main/res/layout/expiration_dialog.xml b/app/src/main/res/layout/expiration_dialog.xml deleted file mode 100644 index 75f5988cab..0000000000 --- a/app/src/main/res/layout/expiration_dialog.xml +++ /dev/null @@ -1,37 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<RelativeLayout - xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:background="?colorPrimary" - android:orientation="vertical" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_gravity="center" - android:gravity="center"> - - <cn.carbswang.android.numberpickerview.library.NumberPickerView - android:id="@+id/expiration_number_picker" - android:contentDescription="@string/AccessibilityId_disappearing_messages_time_picker" - android:layout_alignParentTop="true" - app:npv_WrapSelectorWheel="false" - app:npv_DividerColor="#cbc8ea" - app:npv_TextColorNormal="?android:textColorPrimary" - app:npv_TextColorSelected="?android:textColorPrimary" - app:npv_ItemPaddingVertical="20dp" - app:npv_TextColorHint="@color/grey_600" - app:npv_TextSizeNormal="16sp" - app:npv_TextSizeSelected="16sp" - android:layout_width="match_parent" - android:layout_height="wrap_content"/> - - <TextView - android:id="@+id/expiration_details" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_below="@id/expiration_number_picker" - android:minLines="3" - android:padding="20dp" - tools:text="Your messages will not expire."/> - -</RelativeLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/expiration_timer_menu.xml b/app/src/main/res/layout/expiration_timer_menu.xml deleted file mode 100644 index 5139045021..0000000000 --- a/app/src/main/res/layout/expiration_timer_menu.xml +++ /dev/null @@ -1,32 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<FrameLayout - xmlns:android="http://schemas.android.com/apk/res/android" - style="?android:attr/actionButtonStyle" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:clickable="true" - android:focusable="true" - android:gravity="center"> - - <ImageView - android:id="@+id/menu_badge_icon" - android:layout_width="16dp" - android:layout_height="16dp" - android:layout_gravity="center" - android:src="@drawable/ic_timer" - android:background="@color/transparent" - android:scaleType="fitCenter"/> - - <TextView - android:id="@+id/expiration_badge" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_gravity="bottom|center_horizontal" - android:gravity="center_horizontal|bottom" - android:paddingBottom="3dp" - android:paddingTop="1dp" - android:background="@color/transparent" - android:textColor="?android:textColorPrimary" - android:textSize="10sp" /> - -</FrameLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/export_logs_widget.xml b/app/src/main/res/layout/export_logs_widget.xml index 95c681d397..56f6bc07df 100644 --- a/app/src/main/res/layout/export_logs_widget.xml +++ b/app/src/main/res/layout/export_logs_widget.xml @@ -1,9 +1,10 @@ <?xml version="1.0" encoding="utf-8"?> -<FrameLayout - xmlns:android="http://schemas.android.com/apk/res/android" +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> + <TextView + android:id="@+id/export_logs_button" android:layout_gravity="center" style="@style/Widget.Session.Button.Common.Filled" android:textStyle="bold" @@ -11,5 +12,6 @@ android:paddingHorizontal="@dimen/medium_spacing" android:paddingVertical="12dp" android:layout_width="wrap_content" - android:layout_height="wrap_content"/> + android:layout_height="wrap_content" /> + </FrameLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_create_group.xml b/app/src/main/res/layout/fragment_create_group.xml index af685fa9f6..1e901c753c 100644 --- a/app/src/main/res/layout/fragment_create_group.xml +++ b/app/src/main/res/layout/fragment_create_group.xml @@ -62,10 +62,14 @@ android:layout_marginBottom="@dimen/medium_spacing" android:contentDescription="@string/AccessibilityId_group_name_input" android:hint="@string/activity_create_closed_group_edit_text_hint" - android:maxLength="30" + android:imeOptions="actionDone" + android:inputType="textCapWords" + android:maxLength="@integer/max_group_and_community_name_length_chars" + android:maxLines="1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/titleText" /> + app:layout_constraintTop_toBottomOf="@id/titleText" + tools:ignore="ContentDescription" /> <org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView android:id="@+id/contactSearch" diff --git a/app/src/main/res/layout/fragment_enter_community_url.xml b/app/src/main/res/layout/fragment_enter_community_url.xml index 25da784762..1fcfecd654 100644 --- a/app/src/main/res/layout/fragment_enter_community_url.xml +++ b/app/src/main/res/layout/fragment_enter_community_url.xml @@ -18,6 +18,7 @@ <EditText android:id="@+id/communityUrlEditText" + android:contentDescription="@string/AccessibilityId_community_input_box" style="@style/SmallSessionEditText" android:layout_width="match_parent" android:layout_height="64dp" @@ -91,6 +92,7 @@ <Button android:id="@+id/joinCommunityButton" style="@style/Widget.Session.Button.Common.ProminentOutline" + android:contentDescription="@string/AccessibilityId_join_community_button" android:layout_width="196dp" android:layout_height="@dimen/medium_button_height" android:layout_marginVertical="@dimen/large_spacing" diff --git a/app/src/main/res/layout/fragment_user_details_bottom_sheet.xml b/app/src/main/res/layout/fragment_user_details_bottom_sheet.xml index 4c3bef97d4..eb0ccd9961 100644 --- a/app/src/main/res/layout/fragment_user_details_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_user_details_bottom_sheet.xml @@ -29,6 +29,8 @@ android:id="@+id/nameTextViewContainer" android:layout_width="match_parent" android:layout_height="wrap_content" + android:paddingStart="@dimen/medium_spacing" + android:paddingEnd="@dimen/medium_spacing" android:gravity="center" android:orientation="horizontal" android:layout_centerInParent="true" @@ -42,6 +44,7 @@ android:id="@+id/nameTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_gravity="center" android:layout_marginStart="@dimen/small_spacing" android:layout_marginEnd="@dimen/small_spacing" @@ -57,6 +60,7 @@ android:layout_height="22dp" android:contentDescription="@string/AccessibilityId_edit_user_nickname" android:paddingTop="2dp" + android:layout_marginEnd="20dp" android:src="@drawable/ic_baseline_edit_24" /> </LinearLayout> @@ -73,6 +77,7 @@ android:id="@+id/cancelNicknameEditingButton" android:layout_width="24dp" android:layout_height="24dp" + android:layout_marginLeft="@dimen/large_spacing" android:contentDescription="@string/AccessibilityId_cancel" android:src="@drawable/ic_baseline_clear_24" /> @@ -82,12 +87,12 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" - android:layout_marginHorizontal="@dimen/small_spacing" android:contentDescription="@string/AccessibilityId_username" android:textAlignment="center" android:paddingVertical="12dp" android:inputType="text" - android:singleLine="true" + android:maxLength="@integer/max_user_nickname_length_chars" + android:maxLines="1" android:imeOptions="actionDone" android:textColorHint="?android:textColorSecondary" android:hint="@string/fragment_user_details_bottom_sheet_edit_text_hint" /> @@ -96,6 +101,7 @@ android:id="@+id/saveNicknameButton" android:layout_width="24dp" android:layout_height="24dp" + android:layout_marginRight="@dimen/large_spacing" android:contentDescription="@string/AccessibilityId_apply" android:src="@drawable/ic_baseline_done_24" /> diff --git a/app/src/main/res/layout/item_selectable.xml b/app/src/main/res/layout/item_selectable.xml index 886bde7332..0dcb4d526a 100644 --- a/app/src/main/res/layout/item_selectable.xml +++ b/app/src/main/res/layout/item_selectable.xml @@ -1,12 +1,10 @@ <?xml version="1.0" encoding="utf-8"?> -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" - android:id="@+id/backgroundContainer" + xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?selectableItemBackground" - android:gravity="center_vertical" - android:orientation="horizontal" android:paddingHorizontal="@dimen/medium_spacing" android:paddingVertical="@dimen/small_spacing"> @@ -14,18 +12,44 @@ android:id="@+id/titleTextView" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_margin="@dimen/medium_spacing" - android:layout_weight="1" + android:layout_marginTop="@dimen/small_spacing" android:ellipsize="end" android:lines="1" android:textSize="@dimen/text_size" - tools:text="@tools:sample/full_names" /> + android:textStyle="bold" + app:layout_goneMarginBottom="@dimen/small_spacing" + app:layout_constraintEnd_toStartOf="@id/selectButton" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/subtitleTextView" + tools:text="@tools:sample/cities" /> + + <TextView + android:id="@+id/subtitleTextView" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/small_spacing" + android:ellipsize="end" + android:lines="1" + android:textSize="@dimen/very_small_font_size" + android:visibility="gone" + app:layout_constraintEnd_toStartOf="@id/selectButton" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/titleTextView" + tools:text="@tools:sample/full_names" + tools:visibility="visible"/> <View android:id="@+id/selectButton" android:layout_width="@dimen/small_radial_size" android:layout_height="@dimen/small_radial_size" android:background="@drawable/padded_circle_accent_select" - android:foreground="@drawable/radial_multi_select" /> + android:foreground="@drawable/radial_multi_select" + app:layout_constraintHorizontal_bias="1" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toEndOf="@id/titleTextView" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintEnd_toEndOf="parent"/> -</LinearLayout> \ No newline at end of file +</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/longmessage_activity.xml b/app/src/main/res/layout/longmessage_activity.xml deleted file mode 100644 index 0436fdae2f..0000000000 --- a/app/src/main/res/layout/longmessage_activity.xml +++ /dev/null @@ -1,23 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<ScrollView - xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:padding="16dp"> - - <TextView - android:textSize="@dimen/text_size" - android:focusable="true" - android:textColorLink="?android:textColorPrimary" - android:id="@+id/longmessage_text" - android:layout_width="match_parent" - android:layout_height="wrap_content"/> - - </LinearLayout> - -</ScrollView> \ No newline at end of file diff --git a/app/src/main/res/layout/preference_widget_progress.xml b/app/src/main/res/layout/preference_widget_progress.xml index 3cce4b9483..4b44b9a55a 100644 --- a/app/src/main/res/layout/preference_widget_progress.xml +++ b/app/src/main/res/layout/preference_widget_progress.xml @@ -1,23 +1,19 @@ <?xml version="1.0" encoding="utf-8"?> -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:id="@+id/container" - android:orientation="vertical" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:paddingBottom="16dp" - android:gravity="bottom"> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/export_progress_container" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="bottom" > - <ProgressBar android:id="@+id/progress_bar" - style="?android:attr/progressBarStyleHorizontal" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:indeterminate="true"/> - - <TextView android:id="@+id/progress_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:gravity="center" - tools:text="1345 messages so far"/> + <ProgressBar + android:id="@+id/export_progress_bar" + style="?android:attr/progressBarStyleHorizontal" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:indeterminate="true" + android:visibility="invisible" /> </LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/reactions_pill.xml b/app/src/main/res/layout/reactions_pill.xml index c87f29d82c..a88673cba8 100644 --- a/app/src/main/res/layout/reactions_pill.xml +++ b/app/src/main/res/layout/reactions_pill.xml @@ -15,7 +15,7 @@ android:layout_width="17dp" android:layout_height="17dp" /> - <Space + <View android:id="@+id/reactions_pill_spacer" android:layout_width="4dp" android:layout_height="wrap_content" /> diff --git a/app/src/main/res/layout/view_contact.xml b/app/src/main/res/layout/view_contact.xml index 3053046d8c..ceb4304cc7 100644 --- a/app/src/main/res/layout/view_contact.xml +++ b/app/src/main/res/layout/view_contact.xml @@ -25,6 +25,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="@dimen/medium_spacing" + android:maxLength="@integer/max_user_nickname_length_chars" android:maxLines="1" android:textAlignment="viewStart" android:ellipsize="end" diff --git a/app/src/main/res/layout/view_control_message.xml b/app/src/main/res/layout/view_control_message.xml index 03923677db..56b0c91867 100644 --- a/app/src/main/res/layout/view_control_message.xml +++ b/app/src/main/res/layout/view_control_message.xml @@ -29,6 +29,16 @@ tools:src="@drawable/ic_timer" tools:visibility="visible"/> + <org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView + android:id="@+id/expirationTimerView" + android:layout_width="12dp" + android:layout_height="12dp" + android:layout_marginBottom="@dimen/small_spacing" + android:visibility="gone" + app:tint="?android:textColorPrimary" + tools:src="@drawable/ic_timer" + tools:visibility="visible"/> + <TextView android:id="@+id/textView" android:contentDescription="@string/AccessibilityId_control_message" @@ -38,6 +48,37 @@ android:textColor="?android:textColorPrimary" android:textSize="@dimen/very_small_font_size" android:textStyle="bold" - tools:text="@string/MessageRecord_you_disabled_disappearing_messages" /> + tools:text="You disabled disappearing messages" /> + + <FrameLayout + android:id="@+id/call_view" + style="@style/CallMessage" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal"> + + <TextView + android:id="@+id/call_text_view" + android:textColor="?message_received_text_color" + android:textAlignment="center" + android:layout_gravity="center" + tools:text="You missed a call" + android:layout_width="match_parent" + android:layout_height="match_parent" + app:drawableTint="?message_received_text_color" + app:drawableStartCompat="@drawable/ic_missed_call" /> + + </FrameLayout> + + <TextView + android:id="@+id/followSetting" + style="@style/Widget.Session.Button.Common.Borderless" + android:layout_marginTop="4dp" + android:textColor="@color/accent_green" + android:textSize="@dimen/very_small_font_size" + android:text="@string/MessageRecord_follow_setting" + android:contentDescription="@string/AccessibilityId_follow_setting" + android:layout_width="match_parent" + android:layout_height="wrap_content"/> </LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/view_conversation.xml b/app/src/main/res/layout/view_conversation.xml index 12a7a8ac8c..ed3cc66969 100644 --- a/app/src/main/res/layout/view_conversation.xml +++ b/app/src/main/res/layout/view_conversation.xml @@ -165,7 +165,7 @@ android:maxLines="1" android:textColor="?android:textColorPrimary" android:textSize="@dimen/medium_font_size" - tools:text="Sorry, gotta go fight crime again" /> + tools:text="Sorry, gotta go fight crime again - and more text to make it ellipsize" /> <include layout="@layout/view_typing_indicator" android:id="@+id/typingIndicatorView" diff --git a/app/src/main/res/layout/view_conversation_action_bar.xml b/app/src/main/res/layout/view_conversation_action_bar.xml new file mode 100644 index 0000000000..db51410b86 --- /dev/null +++ b/app/src/main/res/layout/view_conversation_action_bar.xml @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="?attr/actionBarSize" + android:orientation="horizontal" + android:gravity="center_vertical"> + + <org.thoughtcrime.securesms.components.ProfilePictureView + android:id="@+id/profilePictureView" + android:layout_width="@dimen/medium_profile_picture_size" + android:layout_height="@dimen/medium_profile_picture_size" + android:layout_marginTop="@dimen/medium_spacing" + android:layout_marginStart="@dimen/medium_spacing" + android:layout_marginBottom="@dimen/medium_spacing" /> + + <LinearLayout + android:id="@+id/conversationTitleContainer" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="center_horizontal|center_vertical" + android:orientation="vertical"> + + <TextView + android:id="@+id/conversationTitleView" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:contentDescription="@string/AccessibilityId_conversation_header_name" + tools:text="@tools:sample/full_names" + android:textColor="?android:textColorPrimary" + android:textStyle="bold" + android:textSize="@dimen/large_font_size" + android:maxLines="1" + android:ellipsize="end" /> + + <androidx.viewpager2.widget.ViewPager2 + android:id="@+id/settings_pager" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/very_small_spacing"/> + + <com.google.android.material.tabs.TabLayout + android:id="@+id/settings_tab_layout" + android:layout_width="wrap_content" + android:layout_height="@dimen/very_small_spacing" + app:tabBackground="@drawable/tab_indicator_dot" + app:tabGravity="center" + app:tabIndicator="@null" + app:tabPaddingStart="@dimen/very_small_spacing" + app:tabPaddingEnd="@dimen/very_small_spacing"/> + + </LinearLayout> + +</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/view_conversation_setting.xml b/app/src/main/res/layout/view_conversation_setting.xml new file mode 100644 index 0000000000..688aa01f37 --- /dev/null +++ b/app/src/main/res/layout/view_conversation_setting.xml @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="center_horizontal"> + + <ImageView + android:id="@+id/leftArrowImageView" + android:layout_width="14dp" + android:layout_height="14dp" + android:layout_gravity="center" + android:alpha="0.6" + android:visibility="gone" + android:src="@drawable/ic_baseline_keyboard_arrow_left_24dp" + app:tint="?android:textColorPrimary" + tools:visibility="visible" /> + + <ImageView + android:id="@+id/iconImageView" + android:layout_width="14dp" + android:layout_height="14dp" + android:layout_gravity="center" + android:layout_marginEnd="4dp" + android:alpha="0.6" + android:visibility="gone" + app:tint="?android:textColorPrimary" + tools:src="@drawable/ic_outline_notifications_off_24" + tools:visibility="visible" /> + + <TextView + android:id="@+id/titleView" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:alpha="0.6" + android:ellipsize="end" + android:maxLines="1" + android:layout_gravity="center" + android:textColor="?android:textColorPrimary" + android:textSize="@dimen/very_small_font_size" + tools:text="Muted" /> + + <ImageView + android:id="@+id/rightArrowImageView" + android:layout_width="14dp" + android:layout_height="14dp" + android:layout_gravity="center" + android:alpha="0.6" + android:visibility="gone" + android:src="@drawable/ic_baseline_keyboard_arrow_right_24dp" + app:tint="?android:textColorPrimary" + tools:visibility="visible" /> + +</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/view_document.xml b/app/src/main/res/layout/view_document.xml index 713b079601..c760a1a592 100644 --- a/app/src/main/res/layout/view_document.xml +++ b/app/src/main/res/layout/view_document.xml @@ -10,10 +10,17 @@ android:gravity="center" android:contentDescription="@string/AccessibilityId_document"> + <com.google.android.material.progressindicator.CircularProgressIndicator + android:id="@+id/documentViewProgress" + style="@style/Widget.Material3.CircularProgressIndicator.Small" + android:indeterminate="true" + android:layout_width="36dp" + android:layout_height="36dp"/> + <ImageView android:id="@+id/documentViewIconImageView" - android:layout_width="24dp" - android:layout_height="24dp" + android:layout_width="36dp" + android:layout_height="36dp" android:src="@drawable/ic_document_large_light" app:tint="?android:textColorPrimary" /> diff --git a/app/src/main/res/layout/view_input_bar.xml b/app/src/main/res/layout/view_input_bar.xml index 178f9e1e6d..22240867c1 100644 --- a/app/src/main/res/layout/view_input_bar.xml +++ b/app/src/main/res/layout/view_input_bar.xml @@ -12,8 +12,11 @@ android:layout_height="1px" android:background="@color/separator" /> - <FrameLayout + <!-- Additional content layout is a LinearLayout with a vertical split (i.e., it uses rows) to + allow multiple Views to exist, specifically both QuoteDraft and LinkPreviewDraft Views --> + <LinearLayout android:id="@+id/inputBarAdditionalContentContainer" + android:orientation="vertical" android:layout_width="match_parent" android:layout_height="wrap_content" /> diff --git a/app/src/main/res/layout/view_profile_overflow.xml b/app/src/main/res/layout/view_profile_overflow.xml new file mode 100644 index 0000000000..4214c76548 --- /dev/null +++ b/app/src/main/res/layout/view_profile_overflow.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="?android:attr/actionBarSize" + android:layout_height="?android:attr/actionBarSize"> + + <LinearLayout + android:layout_width="@dimen/small_profile_picture_size" + android:layout_height="@dimen/small_profile_picture_size" + android:layout_gravity="center" + android:background="?attr/selectableItemBackgroundBorderless"> + + <include + android:id="@+id/profilePictureView" + layout="@layout/view_profile_picture" /> + + </LinearLayout> + +</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/view_visible_message.xml b/app/src/main/res/layout/view_visible_message.xml index c9badb415d..1e099e319e 100644 --- a/app/src/main/res/layout/view_visible_message.xml +++ b/app/src/main/res/layout/view_visible_message.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> -<org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" @@ -8,45 +8,11 @@ android:layout_height="wrap_content" android:orientation="vertical"> - <androidx.constraintlayout.widget.ConstraintLayout - android:id="@+id/unreadMarkerContainer" + <ViewStub android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginBottom="@dimen/small_spacing" - android:visibility="gone" - tools:visibility="visible"> - <View - android:layout_width="0dp" - android:layout_height="1dp" - android:layout_marginStart="@dimen/medium_spacing" - android:layout_marginEnd="@dimen/small_spacing" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toStartOf="@id/unreadMarker" - android:background="?android:colorAccent" /> - <TextView - android:id="@+id/unreadMarker" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toEndOf="parent" - android:text="@string/unread_marker" - android:gravity="center" - android:textColor="?android:colorAccent" - android:textSize="@dimen/small_font_size" - android:textStyle="bold" /> - <View - android:layout_width="0dp" - android:layout_height="1dp" - android:layout_marginStart="@dimen/small_spacing" - android:layout_marginEnd="@dimen/medium_spacing" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toEndOf="@id/unreadMarker" - app:layout_constraintEnd_toEndOf="parent" - android:background="?android:colorAccent" /> - </androidx.constraintlayout.widget.ConstraintLayout> + android:id="@+id/unreadMarkerContainerStub" + android:layout="@layout/viewstub_visible_message_marker_container" /> <TextView android:id="@+id/dateBreakTextView" @@ -126,23 +92,13 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" /> - <org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView - android:id="@+id/expirationTimerView" - android:layout_width="12dp" - android:layout_height="12dp" - android:layout_gravity="center_vertical" - android:layout_marginHorizontal="@dimen/small_spacing" - android:contentDescription="@string/AccessibilityId_timer_icon" - android:visibility="invisible" - tools:visibility="visible" - tools:src="@drawable/timer60" - tools:tint="@color/black"/> - </LinearLayout> </FrameLayout> - <include layout="@layout/view_emoji_reactions" + <ViewStub + android:layout="@layout/view_emoji_reactions" android:id="@+id/emojiReactionsView" + android:inflatedId="@+id/emojiReactionsView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:visibility="gone" @@ -150,29 +106,42 @@ app:layout_constraintStart_toStartOf="@+id/messageInnerContainer" app:layout_constraintTop_toBottomOf="@id/messageInnerContainer" /> - <TextView - android:id="@+id/messageStatusTextView" + <LinearLayout + android:id="@+id/statusContainer" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginEnd="2dp" - android:layout_marginBottom="1dp" - app:layout_constraintTop_toTopOf="@id/messageStatusImageView" - app:layout_constraintBottom_toBottomOf="@id/messageStatusImageView" - app:layout_constraintEnd_toStartOf="@id/messageStatusImageView" - android:textSize="@dimen/very_small_font_size" - android:textColor="@color/classic_dark_1" - tools:text="Sent" /> - - <ImageView - android:id="@+id/messageStatusImageView" - android:layout_width="12dp" - android:layout_height="12dp" - android:layout_marginTop="5dp" - app:layout_constraintEnd_toEndOf="parent" + android:baselineAligned="true" app:layout_constraintTop_toBottomOf="@+id/emojiReactionsView" - tools:tint="@color/classic_dark_1" - android:src="@drawable/ic_delivery_status_sent" /> + app:layout_constraintStart_toStartOf="@id/messageInnerContainer" + app:layout_constraintHorizontal_bias="1" + app:layout_constraintEnd_toEndOf="parent"> + + <TextView + android:id="@+id/messageStatusTextView" + android:contentDescription="@string/AccessibilityId_message_sent_status" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="2dp" + android:layout_gravity="center" + android:textSize="@dimen/very_small_font_size" + tools:text="Sent" /> + + <ImageView + android:id="@+id/messageStatusImageView" + android:layout_width="12dp" + android:layout_height="12dp" + android:layout_gravity="center" + android:src="@drawable/ic_delivery_status_sent" /> + + <org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView + android:id="@+id/expirationTimerView" + android:layout_width="12dp" + android:layout_height="12dp" + android:layout_gravity="center" + android:tint="?message_status_color" /> + + </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout> -</org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView> \ No newline at end of file +</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/view_visible_message_content.xml b/app/src/main/res/layout/view_visible_message_content.xml index e897bcea58..2c56229a8a 100644 --- a/app/src/main/res/layout/view_visible_message_content.xml +++ b/app/src/main/res/layout/view_visible_message_content.xml @@ -36,15 +36,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content"/> - <include layout="@layout/view_voice_message" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toStartOf="parent" - android:visibility="gone" - android:id="@+id/voiceMessageView" - android:layout_width="160dp" - android:layout_height="36dp"/> - <include layout="@layout/view_open_group_invitation" tools:visibility="gone" app:layout_constraintTop_toTopOf="parent" @@ -75,6 +66,15 @@ android:layout_width="wrap_content" android:layout_height="wrap_content"/> + <include layout="@layout/view_voice_message" + app:layout_constraintTop_toBottomOf="@id/quoteView" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + android:visibility="gone" + android:id="@+id/voiceMessageView" + android:layout_width="160dp" + android:layout_height="36dp"/> + <include layout="@layout/view_link_preview" app:layout_constraintTop_toBottomOf="@+id/quoteView" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/viewstub_visible_message_marker_container.xml b/app/src/main/res/layout/viewstub_visible_message_marker_container.xml new file mode 100644 index 0000000000..d9a1cc15d0 --- /dev/null +++ b/app/src/main/res/layout/viewstub_visible_message_marker_container.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/unreadMarkerContainer" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/small_spacing" + android:gravity="center" + android:orientation="horizontal" + android:visibility="gone" + tools:visibility="visible"> + + <View + android:layout_width="0dp" + android:layout_height="1dp" + android:layout_marginStart="@dimen/medium_spacing" + android:layout_marginEnd="@dimen/small_spacing" + android:layout_weight="1" + android:background="?android:colorAccent" /> + + <TextView + android:id="@+id/unreadMarker" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center" + android:text="@string/unread_marker" + android:textColor="?android:colorAccent" + android:textSize="@dimen/small_font_size" + android:textStyle="bold" /> + + <View + android:layout_width="0dp" + android:layout_height="1dp" + android:layout_marginStart="@dimen/small_spacing" + android:layout_marginEnd="@dimen/medium_spacing" + android:layout_weight="1" + android:background="?android:colorAccent" /> +</LinearLayout> diff --git a/app/src/main/res/menu/menu_conversation_expiration_off.xml b/app/src/main/res/menu/menu_conversation_expiration.xml similarity index 67% rename from app/src/main/res/menu/menu_conversation_expiration_off.xml rename to app/src/main/res/menu/menu_conversation_expiration.xml index 939220009c..e589252237 100644 --- a/app/src/main/res/menu/menu_conversation_expiration_off.xml +++ b/app/src/main/res/menu/menu_conversation_expiration.xml @@ -4,8 +4,7 @@ <item android:title="@string/conversation_expiring_off__disappearing_messages" - android:contentDescription="@string/AccessibilityId_disappearing_messages" - android:id="@+id/menu_expiring_messages_off" - android:icon="@drawable/ic_baseline_timer_off_24" /> + android:id="@+id/menu_expiring_messages" + android:contentDescription="@string/AccessibilityId_disappearing_messages" /> </menu> \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_expiration_on.xml b/app/src/main/res/menu/menu_conversation_expiration_on.xml deleted file mode 100644 index 88775cf71d..0000000000 --- a/app/src/main/res/menu/menu_conversation_expiration_on.xml +++ /dev/null @@ -1,13 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<menu - xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto"> - - <item - android:id="@+id/menu_expiring_messages" - android:contentDescription="@string/AccessibilityId_disappearing_messages_timer" - app:actionLayout="@layout/expiration_timer_menu" - app:showAsAction="always" - android:title="@string/menu_conversation_expiring_on__messages_expiring" /> - -</menu> \ No newline at end of file diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 0aa6b5a88d..d9d26e6625 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -7,7 +7,7 @@ <string name="ban">Ù…ŰłŰŻÙˆŰŻ</string> <string name="save">Ű°ŰźÛŒŰ±Ù‡</string> <string name="note_to_self">ÛŒŰ§ŰŻŰŻŰ§ŰŽŰȘ ŰšÙ‡ ŰźÙˆŰŻ</string> - <string name="version_s">Ù†ŰłŰźÙ‡</string> + <string name="version_s">%s Ù†ŰłŰźÙ‡</string> <!-- AbstractNotificationBuilder --> <string name="AbstractNotificationBuilder_new_message">ÙŸÛŒŰ§Ù… ŰŹŰŻÛŒŰŻ</string> <!-- AlbumThumbnailView --> diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index d8aedf3dea..ae38d0c057 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -319,11 +319,6 @@ <attr name="doc_downloadButtonTint" format="color" /> </declare-styleable> - <declare-styleable name="ConversationItemFooter"> - <attr name="footer_text_color" format="color" /> - <attr name="footer_icon_color" format="color" /> - </declare-styleable> - <declare-styleable name="ConversationItemThumbnail"> <attr name="conversationThumbnail_minWidth" format="dimension" /> <attr name="conversationThumbnail_maxWidth" format="dimension" /> diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 196e587cdc..eae5a2b167 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -165,4 +165,6 @@ <color name="danger_dark">#FF3A3A</color> <color name="danger_light">#E12D19</color> + <color name="outdated_client_banner_background_color">#00F782</color> + </resources> diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 99e0aa197f..6471f73e52 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -2,6 +2,7 @@ <resources> <!-- Font Sizes --> + <dimen name="tiny_font_size">9sp</dimen> <dimen name="very_small_font_size">12sp</dimen> <dimen name="small_font_size">15sp</dimen> <dimen name="medium_font_size">17sp</dimen> @@ -10,6 +11,7 @@ <dimen name="massive_font_size">50sp</dimen> <!-- Element Sizes --> + <dimen name="dialog_button_height">60dp</dimen> <dimen name="small_button_height">34dp</dimen> <dimen name="medium_button_height">38dp</dimen> <dimen name="large_button_height">54dp</dimen> diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml index 4d1a6c4077..bb3cd8a804 100644 --- a/app/src/main/res/values/integers.xml +++ b/app/src/main/res/values/integers.xml @@ -6,4 +6,7 @@ <integer name="reaction_scrubber_reveal_offset">100</integer> <integer name="reaction_scrubber_hide_duration">150</integer> <integer name="reaction_scrubber_emoji_reveal_duration_start_delay_factor">10</integer> + + <integer name="max_user_nickname_length_chars">35</integer> + <integer name="max_group_and_community_name_length_chars">35</integer> </resources> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2d46f68605..640b9f005d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -56,7 +56,10 @@ <string name="AccessibilityId_new_conversation_button">New conversation button</string> <string name="AccessibilityId_new_direct_message">New direct message</string> <string name="AccessibilityId_create_group">Create group</string> - <string name="AccessibilityId_join_community">Join community</string> + <string name="AccessibilityId_join_community">Join community button</string> + <!-- Join community pop up --> + <string name="AccessibilityId_community_input_box">Community input</string> + <string name="AccessibilityId_join_community_button">Join community button</string> <!-- Conversation options (three dots menu)--> <string name="AccessibilityId_all_media">All media</string> <string name="AccessibilityId_search">Search</string> @@ -82,14 +85,14 @@ <!-- Conversation icons --> <string name="AccessibilityId_call_button">Call button</string> <string name="AccessibilityId_settings">Settings</string> - <string name="AccessibilityId_disappearing_messages_timer">Disappearing messages timer</string> + <string name="AccessibilityId_confirm">Confirm</string> <string name="AccessibilityId_disappearing_messages_time_picker">Time selector</string> <string name="AccessibilityId_accept_message_request_button">Accept message request</string> <string name="AccessibilityId_decline_message_request_button">Decline message request</string> <string name="AccessibilityId_block_message_request_button">Block message request</string> <string name="AccessibilityId_timer_icon">Timer icon</string> <!-- Configuration messages --> - <string name="AccessibilityId_control_message">Configuration message</string> + <string name="AccessibilityId_control_message">Control message</string> <string name="AccessibilityId_blocked_banner">Blocked banner</string> <string name="AccessibilityId_blocked_banner_text">Blocked banner text</string> <!--New Session --> @@ -114,11 +117,9 @@ <string name="AccessibilityId_download_media">Download media</string> <string name="AccessibilityId_dont_download_media">Don\'t download media</string> <!-- Conversation View--> - <string name="AccessibilityId_message_sent_status_tick">Message sent status: Sent</string> - <string name="AccessibilityId_message_sent_status_pending">Message sent status pending</string> - <string name="AccessibilityId_message_sent_status_syncing">Message sent status syncing</string> + <string name="AccessibilityId_message_sent_status">Message sent status: Sent</string> <string name="AccessibilityId_message_request_config_message">Message request has been accepted</string> - <string name="AccessibilityId_message_body">Message Body</string> + <string name="AccessibilityId_message_body">Message body</string> <string name="AccessibilityId_voice_message">Voice message</string> <string name="AccessibilityId_document">Document</string> <string name="AccessibilityId_deleted_message">Deleted message</string> @@ -153,6 +154,17 @@ <!-- Link preview Dialog--> <string name="AccessibilityId_enable_link_preview_button">Enable</string> <string name="AccessibilityId_cancel_link_preview_button">Cancel</string> + + <!-- Disappearing messages --> + <string name="AccessibilityId_disappear_after_read_option">Disappear after read option</string> + <string name="AccessibilityId_disappear_after_send_option">Disappear after send option</string> + <string name="AccessibilityId_disappearing_messages_timer">Disappearing messages timer</string> + <string name="AccessibilityId_set_button">Set button</string> + <string name="AccessibilityId_time_option">Time option</string> + <string name="AccessibilityId_disable_disappearing_messages">Disable disappearing messages</string> + <string name="AccessibilityId_configuration_message">Configuration message</string> + <string name="AccessibilityId_disappearing_messages_type_and_time">Disappearing messages type and time</string> + <string name="AccessibilityId_conversation_header_name">Conversation header name</string> <!-- AbstractNotificationBuilder --> <string name="AbstractNotificationBuilder_new_message">New message</string> <!-- AlbumThumbnailView --> @@ -467,6 +479,7 @@ <string name="conversation_activity__quick_attachment_drawer_record_and_send_audio_description">Record and send audio attachment</string> <string name="conversation_activity__quick_attachment_drawer_lock_record_description">Lock recording of audio attachment</string> <string name="conversation_activity__enable_signal_for_sms">Enable Session for SMS</string> + <string name="conversation_activity__wait_until_attachment_has_finished_downloading">Please wait until attachment has finished downloading</string> <!-- conversation_input_panel --> <string name="conversation_input_panel__slide_to_cancel">Slide to cancel</string> <string name="conversation_input_panel__cancel">Cancel</string> @@ -556,6 +569,12 @@ <string name="arrays__default">Default</string> <string name="arrays__high">High</string> <string name="arrays__max">Max</string> + <string name="arrays__five_minutes">5 Minutes</string> + <string name="arrays__one_hour">1 Hour</string> + <string name="arrays__twelve_hours">12 Hours</string> + <string name="arrays__one_day">1 Day</string> + <string name="arrays__one_week">1 Week</string> + <string name="arrays__two_weeks">2 Weeks</string> <!-- plurals.xml --> <plurals name="hours_ago"> <item quantity="one">%d hour</item> @@ -627,6 +646,9 @@ <string name="preferences_notifications__priority">Priority</string> <string name="preferences_app_protection__screenshot_notifications">Screenshot Notifications</string> <string name="preferences_app_protected__screenshot_notifications_summary">Receive a notification when a contact takes a screenshot of a one-to-one chat.</string> + <string name="preferences__message_requests_category">Message Requests</string> + <string name="preferences__message_requests_title">Community Message Requests</string> + <string name="preferences__message_requests_summary">Allow message requests from Community conversations</string> <!-- **************************************** --> <!-- menus --> <!-- **************************************** --> @@ -887,6 +909,11 @@ <string name="dialog_join_open_group_explanation">Are you sure you want to join the %s open group?</string> <string name="dialog_open_url_title">Open URL?</string> <string name="dialog_open_url_explanation">Are you sure you want to open %s?</string> + <string name="dialog_disappearing_messages_follow_setting_title">Follow Setting</string> + <string name="dialog_disappearing_messages_follow_setting_off_body">Messages you send will no longer disappear. Are you sure you want to turn off disappearing messages?</string> + <string name="dialog_disappearing_messages_follow_setting_on_body">Set your messages to disappear %1$s after they have been %2$s?</string> + <string name="dialog_disappearing_messages_follow_setting_set">Set</string> + <string name="dialog_disappearing_messages_follow_setting_confirm">Confirm</string> <string name="open">Open</string> <string name="copy_url">Copy URL</string> <string name="dialog_link_preview_title">Enable Link Previews?</string> @@ -1020,6 +1047,21 @@ <string name="fragment_enter_community_url_join_button_title">Join</string> <string name="new_conversation_dialog_back_button_content_description">Navigate Back</string> <string name="new_conversation_dialog_close_button_content_description">Close Dialog</string> + <string name="activity_disappearing_messages_title">Disappearing Messages</string> + <string name="activity_disappearing_messages_subtitle">This setting applies to messages you send in this conversation.</string> + <string name="expiration_type_disappear_legacy_description">Original version of disappearing messages.</string> + <string name="expiration_type_disappear_legacy">Legacy</string> + <string name="activity_disappearing_messages_subtitle_sent">Messages disappear after they have been sent.</string> + <string name="expiration_type_disappear_after_read">Disappear After Read</string> + <string name="expiration_type_disappear_after_read_description">Messages delete after they have been read.</string> + <string name="expiration_type_disappear_after_send">Disappear After Send</string> + <string name="expiration_type_disappear_after_send_description">Messages delete after they have been sent.</string> + <string name="disappearing_messages_set_button_title">Set</string> + <string name="activity_disappearing_messages_delete_type">Delete Type</string> + <string name="activity_disappearing_messages_timer">Timer</string> + <string name="activity_disappearing_messages_group_footer">This setting applies to everyone in this conversation.\nOnly group admins can change this setting.</string> + <string name="activity_conversation_outdated_client_banner_text">%s is using an outdated client. Disappearing messages may not work as expected.</string> + <string name="DisappearingMessagesActivity_settings_not_updated">Settings not updated and please try again</string> <string name="ErrorNotifier_migration">Database Upgrade Failed</string> <string name="ErrorNotifier_migration_downgrade">Please contact support to report the error.</string> <string name="delivery_status_syncing">Syncing</string> @@ -1033,9 +1075,11 @@ <string name="activity_home_outdated_client_config">Some of your devices are using outdated versions. Syncing may be unreliable until they are updated.</string> <string name="activity_conversation_empty_state_read_only">There are no messages in <b>%s</b>.</string> + <string name="activity_conversation_empty_state_blocks_community_requests"><b>%s</b> has message requests from Community conversations turned off, so you cannot send them a message.</string> <string name="activity_conversation_empty_state_note_to_self">You have no messages in Note to Self.</string> <string name="activity_conversation_empty_state_default">You have no messages from <b>%s</b>.\nSend a message to start the conversation!</string> <string name="unread_marker">Unread Messages</string> + <string name="auto_deletes_in">Auto-deletes in %1$s</string> </resources> diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 2928fc7718..412eec96db 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -69,6 +69,10 @@ <item name="android:textColor">@color/emoji_tab_text_color</item> </style> + <style name="TextAppearance.Session.ToolbarSubtitle" parent="TextAppearance.MaterialComponents.Caption"> + <item name="android:textSize">11dp</item> + </style> + <!-- TODO These button styles require proper background selectors for up/down visual state. --> <style name="Widget.Session.Button.Common" parent=""> <item name="android:gravity">center</item> @@ -115,6 +119,7 @@ <item name="android:textAllCaps">false</item> <item name="android:textSize">@dimen/small_font_size</item> <item name="android:textColor">?android:textColorPrimary</item> + <item name="android:textStyle">bold</item> </style> <style name="Widget.Session.Button.Dialog.UnimportantText"> @@ -189,6 +194,17 @@ <item name="android:maxLines">1</item> </style> + <style name="CallMessage"> + <item name="android:background">@drawable/call_message_background</item> + <item name="android:textColor">?message_received_text_color</item> + <item name="android:paddingLeft">@dimen/medium_spacing</item> + <item name="android:paddingTop">12dp</item> + <item name="android:paddingRight">@dimen/medium_spacing</item> + <item name="android:paddingBottom">12dp</item> + <item name="android:textSize">15sp</item> + <item name="android:fontFamily">sans-serif-medium</item> + </style> + <style name="FakeChatViewMessageBubble"> <item name="android:paddingLeft">@dimen/medium_spacing</item> <item name="android:paddingTop">12dp</item> diff --git a/app/src/main/res/xml/preferences_app_protection.xml b/app/src/main/res/xml/preferences_app_protection.xml index 12607ee769..425edaf9ed 100644 --- a/app/src/main/res/xml/preferences_app_protection.xml +++ b/app/src/main/res/xml/preferences_app_protection.xml @@ -20,6 +20,12 @@ </PreferenceCategory> + <PreferenceCategory + android:title="@string/preferences__message_requests_category" + android:key="@string/preferences__message_requests_category" + android:persistent="false"> + </PreferenceCategory> + <PreferenceCategory android:title="@string/preferences__read_receipts"> <org.thoughtcrime.securesms.components.SwitchPreferenceCompat android:defaultValue="false" @@ -39,7 +45,7 @@ <PreferenceCategory android:title="@string/preferences__link_previews"> <org.thoughtcrime.securesms.components.SwitchPreferenceCompat - android:defaultValue="true" + android:defaultValue="false" android:key="pref_link_previews" android:summary="@string/preferences__link_previews_summary" android:title="@string/preferences__send_link_previews"/> diff --git a/app/src/main/res/xml/preferences_help.xml b/app/src/main/res/xml/preferences_help.xml index 0372619ea3..9bc9bd13e1 100644 --- a/app/src/main/res/xml/preferences_help.xml +++ b/app/src/main/res/xml/preferences_help.xml @@ -6,39 +6,38 @@ android:key="export_logs" android:title="@string/activity_help_settings__report_bug_title" android:summary="@string/activity_help_settings__report_bug_summary" - android:widgetLayout="@layout/export_logs_widget"/> + android:widgetLayout="@layout/export_logs_widget" /> + + <!-- Note: Having this as `android:layout` rather than `android:layoutWidget` allows it to fit the screen width --> + <Preference android:layout="@layout/preference_widget_progress" /> </PreferenceCategory> <PreferenceCategory> <Preference android:key="translate_session" android:title="@string/activity_help_settings__translate_session" - android:widgetLayout="@layout/preference_external_link" - /> + android:widgetLayout="@layout/preference_external_link" /> </PreferenceCategory> <PreferenceCategory> <Preference android:key="feedback" android:title="@string/activity_help_settings__feedback" - android:widgetLayout="@layout/preference_external_link" - /> + android:widgetLayout="@layout/preference_external_link" /> </PreferenceCategory> <PreferenceCategory> <Preference android:key="faq" android:title="@string/activity_help_settings__faq" - android:widgetLayout="@layout/preference_external_link" - /> + android:widgetLayout="@layout/preference_external_link" /> </PreferenceCategory> <PreferenceCategory> <Preference android:key="support" android:title="@string/activity_help_settings__support" - android:widgetLayout="@layout/preference_external_link" - /> + android:widgetLayout="@layout/preference_external_link" /> </PreferenceCategory> </PreferenceScreen> \ No newline at end of file diff --git a/app/src/play/AndroidManifest.xml b/app/src/play/AndroidManifest.xml new file mode 100644 index 0000000000..b3e5ead475 --- /dev/null +++ b/app/src/play/AndroidManifest.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools"> + + <application tools:node="merge"> + <service + android:name="org.thoughtcrime.securesms.notifications.FirebasePushService" + android:enabled="true" + android:exported="false"> + <intent-filter> + <action android:name="com.google.firebase.MESSAGING_EVENT" /> + </intent-filter> + </service> + </application> + +</manifest> \ No newline at end of file diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushModule.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushModule.kt new file mode 100644 index 0000000000..2dac25072e --- /dev/null +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushModule.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 FirebaseBindingModule { + @Binds + abstract fun bindTokenFetcher(tokenFetcher: FirebaseTokenFetcher): TokenFetcher +} diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushService.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushService.kt new file mode 100644 index 0000000000..2a3b054a58 --- /dev/null +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebasePushService.kt @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.notifications + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import dagger.hilt.android.AndroidEntryPoint +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import javax.inject.Inject + +private const val TAG = "FirebasePushNotificationService" +@AndroidEntryPoint +class FirebasePushService : FirebaseMessagingService() { + + @Inject lateinit var prefs: TextSecurePreferences + @Inject lateinit var pushReceiver: PushReceiver + @Inject lateinit var pushRegistry: PushRegistry + + override fun onNewToken(token: String) { + if (token == prefs.getPushToken()) return + + pushRegistry.register(token) + } + + override fun onMessageReceived(message: RemoteMessage) { + Log.d(TAG, "Received a push notification.") + pushReceiver.onPush(message.data) + } + + override fun onDeletedMessages() { + Log.d(TAG, "Called onDeletedMessages.") + pushRegistry.refresh(true) + } +} diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebaseTokenFetcher.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebaseTokenFetcher.kt new file mode 100644 index 0000000000..d40f160d0b --- /dev/null +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/notifications/FirebaseTokenFetcher.kt @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.notifications + +import com.google.android.gms.tasks.Tasks +import com.google.firebase.iid.FirebaseInstanceId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FirebaseTokenFetcher @Inject constructor(): TokenFetcher { + override suspend fun fetch() = withContext(Dispatchers.IO) { + FirebaseInstanceId.getInstance().instanceId + .also(Tasks::await) + .takeIf { isActive } // don't 'complete' task if we were canceled + ?.run { result?.token ?: throw exception!! } + } +} diff --git a/app/src/sharedTest/java/org/thoughtcrime/securesms/NoOpLogger.kt b/app/src/sharedTest/java/org/thoughtcrime/securesms/NoOpLogger.kt new file mode 100644 index 0000000000..998c3b179d --- /dev/null +++ b/app/src/sharedTest/java/org/thoughtcrime/securesms/NoOpLogger.kt @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms + +import org.session.libsignal.utilities.Log.Logger + +object NoOpLogger: Logger() { + override fun v(tag: String?, message: String?, t: Throwable?) {} + + override fun d(tag: String?, message: String?, t: Throwable?) {} + + override fun i(tag: String?, message: String?, t: Throwable?) {} + + override fun w(tag: String?, message: String?, t: Throwable?) {} + + override fun e(tag: String?, message: String?, t: Throwable?) {} + + override fun wtf(tag: String?, message: String?, t: Throwable?) {} + + override fun blockUntilAllWritesFinished() {} +} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/BaseViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/BaseViewModelTest.kt index d73bc91a6d..a0f67fa699 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/BaseViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/BaseViewModelTest.kt @@ -1,10 +1,20 @@ package org.thoughtcrime.securesms import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import org.junit.BeforeClass import org.junit.Rule +import org.session.libsignal.utilities.Log open class BaseViewModelTest: BaseCoroutineTest() { + companion object { + @BeforeClass + @JvmStatic + fun setupLogger() { + Log.initialize(NoOpLogger) + } + } + @get:Rule var instantExecutorRule = InstantTaskExecutorRule() diff --git a/app/src/test/java/org/thoughtcrime/securesms/MainCoroutineRule.kt b/app/src/test/java/org/thoughtcrime/securesms/MainCoroutineRule.kt new file mode 100644 index 0000000000..2e13ce1df6 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/MainCoroutineRule.kt @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@ExperimentalCoroutinesApi +class MainCoroutineRule(private val dispatcher: TestDispatcher = StandardTestDispatcher()) : + TestWatcher() { + + override fun starting(description: Description) { + super.starting(description) + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + super.finished(description) + Dispatchers.resetMain() + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt new file mode 100644 index 0000000000..a9df5d946e --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModelTest.kt @@ -0,0 +1,579 @@ +package org.thoughtcrime.securesms.conversation.disappearingmessages + +import android.app.Application +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupRecord +import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.MainCoroutineRule +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.ExpiryRadioOption +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCard +import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.ui.GetString +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +private const val THREAD_ID = 1L +private const val LOCAL_NUMBER = "05---local---address" +private val LOCAL_ADDRESS = Address.fromSerialized(LOCAL_NUMBER) +private const val GROUP_NUMBER = "${GroupUtil.COMMUNITY_PREFIX}4133" +private val GROUP_ADDRESS = Address.fromSerialized(GROUP_NUMBER) + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +class DisappearingMessagesViewModelTest { + + @ExperimentalCoroutinesApi + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + @Mock lateinit var application: Application + @Mock lateinit var textSecurePreferences: TextSecurePreferences + @Mock lateinit var messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol + @Mock lateinit var disappearingMessages: DisappearingMessages + @Mock lateinit var threadDb: ThreadDatabase + @Mock lateinit var groupDb: GroupDatabase + @Mock lateinit var storage: Storage + @Mock lateinit var recipient: Recipient + @Mock lateinit var groupRecord: GroupRecord + + @Test + fun `note to self, off, new config`() = runTest { + mock1on1(ExpiryMode.NONE, LOCAL_ADDRESS) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = LOCAL_ADDRESS, + isNoteToSelf = true, + expiryMode = ExpiryMode.NONE, + isNewConfigEnabled = true, + persistedMode = ExpiryMode.NONE, + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_timer, + typeOption(ExpiryMode.NONE, selected = true), + timeOption(ExpiryType.AFTER_SEND, 12.hours), + timeOption(ExpiryType.AFTER_SEND, 1.days), + timeOption(ExpiryType.AFTER_SEND, 7.days), + timeOption(ExpiryType.AFTER_SEND, 14.days) + ) + ) + ) + } + + @Test + fun `note to self, off, old config`() = runTest { + mock1on1(ExpiryMode.NONE, LOCAL_ADDRESS) + + val viewModel = createViewModel(isNewConfigEnabled = false) + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = LOCAL_ADDRESS, + isNoteToSelf = true, + expiryMode = ExpiryMode.Legacy(0), + isNewConfigEnabled = false, + persistedMode = ExpiryMode.Legacy(0), + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_timer, + typeOption(ExpiryMode.NONE, selected = false), + timeOption(ExpiryType.LEGACY, 12.hours), + timeOption(ExpiryType.LEGACY, 1.days), + timeOption(ExpiryType.LEGACY, 7.days), + timeOption(ExpiryType.LEGACY, 14.days) + ) + ) + ) + } + + @Test + fun `group, off, admin, new config`() = runTest { + mockGroup(ExpiryMode.NONE, isAdmin = true) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = true, + isSelfAdmin = true, + address = GROUP_ADDRESS, + isNoteToSelf = false, + expiryMode = ExpiryMode.NONE, + isNewConfigEnabled = true, + persistedMode = ExpiryMode.NONE, + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_timer, + typeOption(ExpiryMode.NONE, selected = true), + timeOption(ExpiryType.AFTER_SEND, 12.hours), + timeOption(ExpiryType.AFTER_SEND, 1.days), + timeOption(ExpiryType.AFTER_SEND, 7.days), + timeOption(ExpiryType.AFTER_SEND, 14.days) + ), + showGroupFooter = true + ) + ) + } + + @Test + fun `group, off, not admin, new config`() = runTest { + mockGroup(ExpiryMode.NONE, isAdmin = false) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = true, + isSelfAdmin = false, + address = GROUP_ADDRESS, + isNoteToSelf = false, + expiryMode = ExpiryMode.NONE, + isNewConfigEnabled = true, + persistedMode = ExpiryMode.NONE, + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_timer, + typeOption(ExpiryMode.NONE, enabled = false, selected = true), + timeOption(ExpiryType.AFTER_SEND, 12.hours, enabled = false), + timeOption(ExpiryType.AFTER_SEND, 1.days, enabled = false), + timeOption(ExpiryType.AFTER_SEND, 7.days, enabled = false), + timeOption(ExpiryType.AFTER_SEND, 14.days, enabled = false) + ), + showGroupFooter = true, + showSetButton = false, + ) + ) + } + + @Test + fun `1-1 conversation, off, new config`() = runTest { + val someAddress = Address.fromSerialized("05---SOME---ADDRESS") + mock1on1(ExpiryMode.NONE, someAddress) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = someAddress, + isNoteToSelf = false, + expiryMode = ExpiryMode.NONE, + isNewConfigEnabled = true, + persistedMode = ExpiryMode.NONE, + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_delete_type, + typeOption(ExpiryMode.NONE, selected = true), + typeOption(12.hours, ExpiryType.AFTER_READ), + typeOption(1.days, ExpiryType.AFTER_SEND) + ) + ) + ) + } + + @Test + fun `1-1 conversation, 12 hours after send, new config`() = runTest { + val time = 12.hours + val someAddress = Address.fromSerialized("05---SOME---ADDRESS") + mock1on1AfterSend(time, someAddress) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = someAddress, + isNoteToSelf = false, + expiryMode = ExpiryMode.AfterSend(12.hours.inWholeSeconds), + isNewConfigEnabled = true, + persistedMode = ExpiryMode.AfterSend(12.hours.inWholeSeconds), + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_delete_type, + typeOption(ExpiryMode.NONE), + typeOption(time, ExpiryType.AFTER_READ), + typeOption(time, ExpiryType.AFTER_SEND, selected = true) + ), + OptionsCard( + R.string.activity_disappearing_messages_timer, + timeOption(ExpiryType.AFTER_SEND, 12.hours, selected = true), + timeOption(ExpiryType.AFTER_SEND, 1.days), + timeOption(ExpiryType.AFTER_SEND, 7.days), + timeOption(ExpiryType.AFTER_SEND, 14.days) + ) + ) + ) + } + + @Test + fun `1-1 conversation, 12 hours legacy, old config`() = runTest { + val time = 12.hours + val someAddress = Address.fromSerialized("05---SOME---ADDRESS") + mock1on1(ExpiryType.LEGACY.mode(time), someAddress) + + val viewModel = createViewModel(isNewConfigEnabled = false) + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = someAddress, + isNoteToSelf = false, + expiryMode = ExpiryType.LEGACY.mode(12.hours), + isNewConfigEnabled = false, + persistedMode = ExpiryType.LEGACY.mode(12.hours), + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_delete_type, + typeOption(ExpiryMode.NONE), + typeOption(time, ExpiryType.LEGACY, selected = true), + typeOption(12.hours, ExpiryType.AFTER_READ, enabled = false), + typeOption(1.days, ExpiryType.AFTER_SEND, enabled = false) + ), + OptionsCard( + R.string.activity_disappearing_messages_timer, + timeOption(ExpiryType.LEGACY, 12.hours, selected = true), + timeOption(ExpiryType.LEGACY, 1.days), + timeOption(ExpiryType.LEGACY, 7.days), + timeOption(ExpiryType.LEGACY, 14.days) + ) + ) + ) + } + + @Test + fun `1-1 conversation, 1 day after send, new config`() = runTest { + val time = 1.days + val someAddress = Address.fromSerialized("05---SOME---ADDRESS") + mock1on1AfterSend(time, someAddress) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = someAddress, + isNoteToSelf = false, + expiryMode = ExpiryMode.AfterSend(1.days.inWholeSeconds), + isNewConfigEnabled = true, + persistedMode = ExpiryMode.AfterSend(1.days.inWholeSeconds), + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_delete_type, + typeOption(ExpiryMode.NONE), + typeOption(12.hours, ExpiryType.AFTER_READ), + typeOption(time, ExpiryType.AFTER_SEND, selected = true) + ), + OptionsCard( + R.string.activity_disappearing_messages_timer, + timeOption(ExpiryType.AFTER_SEND, 12.hours), + timeOption(ExpiryType.AFTER_SEND, 1.days, selected = true), + timeOption(ExpiryType.AFTER_SEND, 7.days), + timeOption(ExpiryType.AFTER_SEND, 14.days) + ) + ) + ) + } + + @Test + fun `1-1 conversation, 1 day after read, new config`() = runTest { + val time = 1.days + val someAddress = Address.fromSerialized("05---SOME---ADDRESS") + + mock1on1AfterRead(time, someAddress) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = someAddress, + isNoteToSelf = false, + expiryMode = ExpiryMode.AfterRead(1.days.inWholeSeconds), + isNewConfigEnabled = true, + persistedMode = ExpiryMode.AfterRead(1.days.inWholeSeconds), + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_delete_type, + typeOption(ExpiryMode.NONE), + typeOption(1.days, ExpiryType.AFTER_READ, selected = true), + typeOption(time, ExpiryType.AFTER_SEND) + ), + OptionsCard( + R.string.activity_disappearing_messages_timer, + timeOption(ExpiryType.AFTER_READ, 5.minutes), + timeOption(ExpiryType.AFTER_READ, 1.hours), + timeOption(ExpiryType.AFTER_READ, 12.hours), + timeOption(ExpiryType.AFTER_READ, 1.days, selected = true), + timeOption(ExpiryType.AFTER_READ, 7.days), + timeOption(ExpiryType.AFTER_READ, 14.days) + ) + ) + ) + } + + @Test + fun `1-1 conversation, init 12 hours after read, then select after send, new config`() = runTest { + val time = 12.hours + val someAddress = Address.fromSerialized("05---SOME---ADDRESS") + + mock1on1AfterRead(time, someAddress) + + val viewModel = createViewModel() + + advanceUntilIdle() + + viewModel.setValue(afterSendMode(1.days)) + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = someAddress, + isNoteToSelf = false, + expiryMode = afterSendMode(1.days), + isNewConfigEnabled = true, + persistedMode = afterReadMode(12.hours), + showDebugOptions = false + ) + ) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + OptionsCard( + R.string.activity_disappearing_messages_delete_type, + typeOption(ExpiryMode.NONE), + typeOption(12.hours, ExpiryType.AFTER_READ), + typeOption(1.days, ExpiryType.AFTER_SEND, selected = true) + ), + OptionsCard( + R.string.activity_disappearing_messages_timer, + timeOption(ExpiryType.AFTER_SEND, 12.hours), + timeOption(ExpiryType.AFTER_SEND, 1.days, selected = true), + timeOption(ExpiryType.AFTER_SEND, 7.days), + timeOption(ExpiryType.AFTER_SEND, 14.days) + ) + ) + ) + } + + private fun timeOption( + type: ExpiryType, + time: Duration, + enabled: Boolean = true, + selected: Boolean = false + ) = ExpiryRadioOption( + value = type.mode(time), + title = GetString(time), + enabled = enabled, + selected = selected + ) + + private fun afterSendMode(time: Duration) = ExpiryMode.AfterSend(time.inWholeSeconds) + private fun afterReadMode(time: Duration) = ExpiryMode.AfterRead(time.inWholeSeconds) + + private fun mock1on1AfterRead(time: Duration, someAddress: Address) { + mock1on1(ExpiryType.AFTER_READ.mode(time), someAddress) + } + + private fun mock1on1AfterSend(time: Duration, someAddress: Address) { + mock1on1(ExpiryType.AFTER_SEND.mode(time), someAddress) + } + + private fun mock1on1(mode: ExpiryMode, someAddress: Address) { + mockStuff(mode) + + whenever(recipient.address).thenReturn(someAddress) + } + + private fun mockGroup(mode: ExpiryMode, isAdmin: Boolean) { + mockStuff(mode) + + whenever(recipient.address).thenReturn(GROUP_ADDRESS) + whenever(recipient.isClosedGroupRecipient).thenReturn(true) + whenever(groupDb.getGroup(any<String>())).thenReturn(Optional.of(groupRecord)) + whenever(groupRecord.admins).thenReturn( + buildList { + if (isAdmin) add(LOCAL_ADDRESS) + } + ) + } + + private fun mockStuff(mode: ExpiryMode) { + val config = config(mode) + whenever(threadDb.getRecipientForThreadId(Mockito.anyLong())).thenReturn(recipient) + whenever(storage.getExpirationConfiguration(Mockito.anyLong())).thenReturn(config) + whenever(textSecurePreferences.getLocalNumber()).thenReturn(LOCAL_NUMBER) + } + + private fun config(mode: ExpiryMode) = ExpirationConfiguration( + threadId = THREAD_ID, + expiryMode = mode, + updatedTimestampMs = 0 + ) + + private fun createViewModel(isNewConfigEnabled: Boolean = true) = DisappearingMessagesViewModel( + THREAD_ID, + application, + textSecurePreferences, + messageExpirationManager, + disappearingMessages, + threadDb, + groupDb, + storage, + isNewConfigEnabled, + showDebugOptions = false + ) +} + +fun typeOption(time: Duration, type: ExpiryType, selected: Boolean = false, enabled: Boolean = true) = + typeOption(type.mode(time), selected, enabled) + +fun typeOption(mode: ExpiryMode, selected: Boolean = false, enabled: Boolean = true) = + ExpiryRadioOption( + mode, + GetString(mode.type.title), + mode.type.subtitle?.let(::GetString), + GetString(mode.type.contentDescription), + selected = selected, + enabled = enabled + ) diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt index 361c5a4382..37303e29d5 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt @@ -1,42 +1,55 @@ package org.thoughtcrime.securesms.conversation.v2 import com.goterl.lazysodium.utils.KeyPair +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import org.hamcrest.CoreMatchers.endsWith import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.notNullValue +import org.hamcrest.CoreMatchers.nullValue import org.hamcrest.MatcherAssert.assertThat import org.junit.Before +import org.junit.BeforeClass import org.junit.Test +import org.mockito.Mockito import org.mockito.Mockito.anyLong import org.mockito.Mockito.anySet -import org.mockito.Mockito.mock import org.mockito.Mockito.verify import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.BaseViewModelTest +import org.thoughtcrime.securesms.NoOpLogger import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ResultOf -import org.mockito.Mockito.`when` as whenever class ConversationViewModelTest: BaseViewModelTest() { - private val repository = mock(ConversationRepository::class.java) - private val storage = mock(Storage::class.java) + private val repository = mock<ConversationRepository>() + private val storage = mock<Storage>() private val threadId = 123L - private val edKeyPair = mock(KeyPair::class.java) + private val edKeyPair = mock<KeyPair>() private lateinit var recipient: Recipient + private lateinit var messageRecord: MessageRecord private val viewModel: ConversationViewModel by lazy { - ConversationViewModel(threadId, edKeyPair, mock(), repository, storage) + ConversationViewModel(threadId, edKeyPair, repository, storage) } @Before fun setUp() { - recipient = mock(Recipient::class.java) + recipient = mock() + messageRecord = mock { record -> + whenever(record.individualRecipient).thenReturn(recipient) + } whenever(repository.maybeGetRecipientForThreadId(anyLong())).thenReturn(recipient) + whenever(repository.recipientUpdateFlow(anyLong())).thenReturn(emptyFlow()) } @Test @@ -45,7 +58,8 @@ class ConversationViewModelTest: BaseViewModelTest() { viewModel.saveDraft(draft) - verify(repository).saveDraft(threadId, draft) + // The above is an async process to wait 100ms to give it a chance to complete + verify(repository, Mockito.timeout(100).times(1)).saveDraft(threadId, draft) } @Test @@ -79,7 +93,7 @@ class ConversationViewModelTest: BaseViewModelTest() { @Test fun `should delete locally`() { - val message = mock(MessageRecord::class.java) + val message = mock<MessageRecord>() viewModel.deleteLocally(message) @@ -88,7 +102,7 @@ class ConversationViewModelTest: BaseViewModelTest() { @Test fun `should emit error message on failure to delete a message for everyone`() = runBlockingTest { - val message = mock(MessageRecord::class.java) + val message = mock<MessageRecord>() val error = Throwable() whenever(repository.deleteForEveryone(anyLong(), any(), any())) .thenReturn(ResultOf.Failure(error)) @@ -101,7 +115,7 @@ class ConversationViewModelTest: BaseViewModelTest() { @Test fun `should emit error message on failure to delete messages without unsend request`() = runBlockingTest { - val message = mock(MessageRecord::class.java) + val message = mock<MessageRecord>() val error = Throwable() whenever(repository.deleteMessageWithoutUnsendRequest(anyLong(), anySet())) .thenReturn(ResultOf.Failure(error)) @@ -138,7 +152,7 @@ class ConversationViewModelTest: BaseViewModelTest() { val error = Throwable() whenever(repository.banAndDeleteAll(anyLong(), any())).thenReturn(ResultOf.Failure(error)) - viewModel.banAndDeleteAll(recipient) + viewModel.banAndDeleteAll(messageRecord) assertThat(viewModel.uiState.first().uiMessages.first().message, endsWith("$error")) } @@ -147,7 +161,7 @@ class ConversationViewModelTest: BaseViewModelTest() { fun `should emit a message on ban user and delete all success`() = runBlockingTest { whenever(repository.banAndDeleteAll(anyLong(), any())).thenReturn(ResultOf.Success(Unit)) - viewModel.banAndDeleteAll(recipient) + viewModel.banAndDeleteAll(messageRecord) assertThat( viewModel.uiState.first().uiMessages.first().message, @@ -181,4 +195,30 @@ class ConversationViewModelTest: BaseViewModelTest() { assertThat(viewModel.uiState.value.uiMessages.size, equalTo(0)) } + @Test + fun `open group recipient should have no blinded recipient`() { + whenever(recipient.isCommunityRecipient).thenReturn(true) + whenever(recipient.isOpenGroupOutboxRecipient).thenReturn(false) + whenever(recipient.isOpenGroupInboxRecipient).thenReturn(false) + assertThat(viewModel.blindedRecipient, nullValue()) + } + + @Test + fun `local recipient should have input and no blinded recipient`() { + whenever(recipient.isLocalNumber).thenReturn(true) + assertThat(viewModel.hidesInputBar(), equalTo(false)) + assertThat(viewModel.blindedRecipient, nullValue()) + } + + @Test + fun `contact recipient should hide input bar if not accepting requests`() { + whenever(recipient.isOpenGroupInboxRecipient).thenReturn(true) + val blinded = mock<Recipient> { + whenever(it.blocksCommunityMessageRequests).thenReturn(true) + } + whenever(repository.maybeGetBlindedRecipient(recipient)).thenReturn(blinded) + assertThat(viewModel.blindedRecipient, notNullValue()) + assertThat(viewModel.hidesInputBar(), equalTo(true)) + } + } \ No newline at end of file diff --git a/app/src/website/kotlin/org/thoughtcrime/securesms/notifications/NoOpPushModule.kt b/app/src/website/kotlin/org/thoughtcrime/securesms/notifications/NoOpPushModule.kt new file mode 100644 index 0000000000..59873c82be --- /dev/null +++ b/app/src/website/kotlin/org/thoughtcrime/securesms/notifications/NoOpPushModule.kt @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.notifications + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class NoOpPushModule { + @Binds + abstract fun bindTokenFetcher(tokenFetcher: NoOpTokenFetcher): TokenFetcher +} diff --git a/app/src/website/kotlin/org/thoughtcrime/securesms/notifications/NoOpTokenFetcher.kt b/app/src/website/kotlin/org/thoughtcrime/securesms/notifications/NoOpTokenFetcher.kt new file mode 100644 index 0000000000..875518354b --- /dev/null +++ b/app/src/website/kotlin/org/thoughtcrime/securesms/notifications/NoOpTokenFetcher.kt @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.notifications + +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NoOpTokenFetcher @Inject constructor() : TokenFetcher { + override suspend fun fetch(): String? = null +} diff --git a/build.gradle b/build.gradle index 7d9857aaf3..9ac76c9d07 100644 --- a/build.gradle +++ b/build.gradle @@ -2,13 +2,22 @@ buildscript { repositories { google() mavenCentral() + if (project.hasProperty('huawei')) maven { + url 'https://developer.huawei.com/repo/' + content { + includeGroup 'com.huawei.agconnect' + } + } } + dependencies { classpath "com.android.tools.build:gradle:$gradlePluginVersion" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" + classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion" classpath "com.google.gms:google-services:$googleServicesVersion" - classpath files('libs/gradle-witness.jar') classpath "com.squareup:javapoet:1.13.0" + classpath "com.google.dagger:hilt-android-gradle-plugin:$daggerVersion" + if (project.hasProperty('huawei')) classpath 'com.huawei.agconnect:agcp:1.9.1.300' } } @@ -44,19 +53,22 @@ allprojects { includeGroupByRegex "org\\.signal.*" } } - maven { - url "https://repo1.maven.org/maven2/com/goterl/lazysodium-android" - content { - includeGroupByRegex "com\\.goterl.*" - } - } jcenter() maven { url "https://jitpack.io" } + if (project.hasProperty('huawei')) maven { + url 'https://developer.huawei.com/repo/' + content { + includeGroup 'com.huawei.android.hms' + includeGroup 'com.huawei.agconnect' + includeGroup 'com.huawei.hmf' + includeGroup 'com.huawei.hms' + } + } } project.ext { androidMinimumSdkVersion = 23 - androidTargetSdkVersion = 33 - androidCompileSdkVersion = 33 + androidTargetSdkVersion = 34 + androidCompileSdkVersion = 34 } } \ No newline at end of file diff --git a/liblazysodium/build.gradle b/liblazysodium/build.gradle index 10f52943d8..e53c7ca016 100644 --- a/liblazysodium/build.gradle +++ b/liblazysodium/build.gradle @@ -1,2 +1,2 @@ configurations.maybeCreate("default") -artifacts.add("default", file('lazysodium.aar')) \ No newline at end of file +artifacts.add("default", file('session-lazysodium-android.aar')) \ No newline at end of file diff --git a/liblazysodium/lazysodium.aar b/liblazysodium/lazysodium.aar deleted file mode 100644 index 4227d956e6..0000000000 Binary files a/liblazysodium/lazysodium.aar and /dev/null differ diff --git a/liblazysodium/session-lazysodium-android.aar b/liblazysodium/session-lazysodium-android.aar new file mode 100644 index 0000000000..aadcac7aad Binary files /dev/null and b/liblazysodium/session-lazysodium-android.aar differ diff --git a/libsession-util/build.gradle b/libsession-util/build.gradle index e4f9ed56f3..f368f15eea 100644 --- a/libsession-util/build.gradle +++ b/libsession-util/build.gradle @@ -26,7 +26,7 @@ android { externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" - version "3.22.1" + version "3.22.1+" } } compileOptions { @@ -42,6 +42,6 @@ dependencies { testImplementation 'junit:junit:4.13.2' implementation(project(":libsignal")) implementation "com.google.protobuf:protobuf-java:$protobufVersion" - androidTestImplementation 'androidx.test.ext:junit:1.1.4' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' } \ No newline at end of file diff --git a/libsession-util/libsession-util b/libsession-util/libsession-util index 7eb8702835..626b6628a2 160000 --- a/libsession-util/libsession-util +++ b/libsession-util/libsession-util @@ -1 +1 @@ -Subproject commit 7eb87028355bfc89950102c52d5b2927a25b2e22 +Subproject commit 626b6628a2af8fff798042416b3b469b8bfc6ecf diff --git a/libsession-util/src/main/cpp/CMakeLists.txt b/libsession-util/src/main/cpp/CMakeLists.txt index d01523a24e..47fee4803c 100644 --- a/libsession-util/src/main/cpp/CMakeLists.txt +++ b/libsession-util/src/main/cpp/CMakeLists.txt @@ -61,6 +61,7 @@ target_link_libraries( # Specifies the target library. PUBLIC libsession::config libsession::crypto + libsodium::sodium-internal # Links the target library to the log library # included in the NDK. ${log-lib}) diff --git a/libsession-util/src/main/cpp/config_base.cpp b/libsession-util/src/main/cpp/config_base.cpp index eed3ec56af..5af6483371 100644 --- a/libsession-util/src/main/cpp/config_base.cpp +++ b/libsession-util/src/main/cpp/config_base.cpp @@ -1,5 +1,6 @@ #include "config_base.h" #include "util.h" +#include "jni_utils.h" extern "C" { JNIEXPORT jboolean JNICALL @@ -82,28 +83,37 @@ Java_network_loki_messenger_libsession_1util_ConfigBase_confirmPushed(JNIEnv *en #pragma clang diagnostic push #pragma ide diagnostic ignored "bugprone-reserved-identifier" -JNIEXPORT jint JNICALL +JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_ConfigBase_merge___3Lkotlin_Pair_2(JNIEnv *env, jobject thiz, jobjectArray to_merge) { - std::lock_guard lock{util::util_mutex_}; - auto conf = ptrToConfigBase(env, thiz); - size_t number = env->GetArrayLength(to_merge); - std::vector<std::pair<std::string,session::ustring>> configs = {}; - for (int i = 0; i < number; i++) { - auto jElement = (jobject) env->GetObjectArrayElement(to_merge, i); - auto pair = extractHashAndData(env, jElement); - configs.push_back(pair); - } - return conf->merge(configs); + return jni_utils::run_catching_cxx_exception_or_throws<jobject>(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConfigBase(env, thiz); + size_t number = env->GetArrayLength(to_merge); + std::vector<std::pair<std::string, session::ustring>> configs = {}; + for (int i = 0; i < number; i++) { + auto jElement = (jobject) env->GetObjectArrayElement(to_merge, i); + auto pair = extractHashAndData(env, jElement); + configs.push_back(pair); + } + auto returned = conf->merge(configs); + auto string_stack = util::build_string_stack(env, returned); + return string_stack; + }); } -JNIEXPORT jint JNICALL +JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_ConfigBase_merge__Lkotlin_Pair_2(JNIEnv *env, jobject thiz, jobject to_merge) { - std::lock_guard lock{util::util_mutex_}; - auto conf = ptrToConfigBase(env, thiz); - std::vector<std::pair<std::string, session::ustring>> configs = {extractHashAndData(env, to_merge)}; - return conf->merge(configs); + return jni_utils::run_catching_cxx_exception_or_throws<jobject>(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConfigBase(env, thiz); + std::vector<std::pair<std::string, session::ustring>> configs = { + extractHashAndData(env, to_merge)}; + auto returned = conf->merge(configs); + auto string_stack = util::build_string_stack(env, returned); + return string_stack; + }); } #pragma clang diagnostic pop diff --git a/libsession-util/src/main/cpp/contacts.cpp b/libsession-util/src/main/cpp/contacts.cpp index 7d04904802..324d0f0ea8 100644 --- a/libsession-util/src/main/cpp/contacts.cpp +++ b/libsession-util/src/main/cpp/contacts.cpp @@ -1,100 +1,121 @@ #include "contacts.h" #include "util.h" +#include "jni_utils.h" extern "C" JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_Contacts_get(JNIEnv *env, jobject thiz, jstring session_id) { - std::lock_guard lock{util::util_mutex_}; - auto contacts = ptrToContacts(env, thiz); - auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); - auto contact = contacts->get(session_id_chars); - env->ReleaseStringUTFChars(session_id, session_id_chars); - if (!contact) return nullptr; - jobject j_contact = serialize_contact(env, contact.value()); - return j_contact; + // If an exception is thrown, return nullptr + return jni_utils::run_catching_cxx_exception_or<jobject>( + [=]() -> jobject { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); + auto contact = contacts->get(session_id_chars); + env->ReleaseStringUTFChars(session_id, session_id_chars); + if (!contact) return nullptr; + jobject j_contact = serialize_contact(env, contact.value()); + return j_contact; + }, + [](const char *) -> jobject { return nullptr; } + ); } extern "C" JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_Contacts_getOrConstruct(JNIEnv *env, jobject thiz, jstring session_id) { - std::lock_guard lock{util::util_mutex_}; - auto contacts = ptrToContacts(env, thiz); - auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); - auto contact = contacts->get_or_construct(session_id_chars); - env->ReleaseStringUTFChars(session_id, session_id_chars); - return serialize_contact(env, contact); + return jni_utils::run_catching_cxx_exception_or_throws<jobject>(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); + auto contact = contacts->get_or_construct(session_id_chars); + env->ReleaseStringUTFChars(session_id, session_id_chars); + return serialize_contact(env, contact); + }); } extern "C" JNIEXPORT void JNICALL Java_network_loki_messenger_libsession_1util_Contacts_set(JNIEnv *env, jobject thiz, jobject contact) { - std::lock_guard lock{util::util_mutex_}; - auto contacts = ptrToContacts(env, thiz); - auto contact_info = deserialize_contact(env, contact, contacts); - contacts->set(contact_info); + jni_utils::run_catching_cxx_exception_or_throws<void>(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto contact_info = deserialize_contact(env, contact, contacts); + contacts->set(contact_info); + }); } extern "C" JNIEXPORT jboolean JNICALL Java_network_loki_messenger_libsession_1util_Contacts_erase(JNIEnv *env, jobject thiz, jstring session_id) { - std::lock_guard lock{util::util_mutex_}; - auto contacts = ptrToContacts(env, thiz); - auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); + return jni_utils::run_catching_cxx_exception_or_throws<jboolean>(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + auto session_id_chars = env->GetStringUTFChars(session_id, nullptr); - bool result = contacts->erase(session_id_chars); - env->ReleaseStringUTFChars(session_id, session_id_chars); - return result; + bool result = contacts->erase(session_id_chars); + env->ReleaseStringUTFChars(session_id, session_id_chars); + return result; + }); } extern "C" #pragma clang diagnostic push #pragma ide diagnostic ignored "bugprone-reserved-identifier" JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_Contacts_00024Companion_newInstance___3B(JNIEnv *env, - jobject thiz, - jbyteArray ed25519_secret_key) { - std::lock_guard lock{util::util_mutex_}; - auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); - auto* contacts = new session::config::Contacts(secret_key, std::nullopt); + jobject thiz, + jbyteArray ed25519_secret_key) { + return jni_utils::run_catching_cxx_exception_or_throws<jobject>(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto *contacts = new session::config::Contacts(secret_key, std::nullopt); - jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); - jmethodID constructor = env->GetMethodID(contactsClass, "<init>", "(J)V"); - jobject newConfig = env->NewObject(contactsClass, constructor, reinterpret_cast<jlong>(contacts)); + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); + jmethodID constructor = env->GetMethodID(contactsClass, "<init>", "(J)V"); + jobject newConfig = env->NewObject(contactsClass, constructor, + reinterpret_cast<jlong>(contacts)); - return newConfig; + return newConfig; + }); } extern "C" JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_Contacts_00024Companion_newInstance___3B_3B( JNIEnv *env, jobject thiz, jbyteArray ed25519_secret_key, jbyteArray initial_dump) { - std::lock_guard lock{util::util_mutex_}; - auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); - auto initial = util::ustring_from_bytes(env, initial_dump); + return jni_utils::run_catching_cxx_exception_or_throws<jobject>(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto secret_key = util::ustring_from_bytes(env, ed25519_secret_key); + auto initial = util::ustring_from_bytes(env, initial_dump); - auto* contacts = new session::config::Contacts(secret_key, initial); + auto *contacts = new session::config::Contacts(secret_key, initial); - jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); - jmethodID constructor = env->GetMethodID(contactsClass, "<init>", "(J)V"); - jobject newConfig = env->NewObject(contactsClass, constructor, reinterpret_cast<jlong>(contacts)); + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); + jmethodID constructor = env->GetMethodID(contactsClass, "<init>", "(J)V"); + jobject newConfig = env->NewObject(contactsClass, constructor, + reinterpret_cast<jlong>(contacts)); - return newConfig; + return newConfig; + }); } #pragma clang diagnostic pop extern "C" JNIEXPORT jobject JNICALL Java_network_loki_messenger_libsession_1util_Contacts_all(JNIEnv *env, jobject thiz) { - std::lock_guard lock{util::util_mutex_}; - auto contacts = ptrToContacts(env, thiz); - jclass stack = env->FindClass("java/util/Stack"); - jmethodID init = env->GetMethodID(stack, "<init>", "()V"); - jobject our_stack = env->NewObject(stack, init); - jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); - for (const auto& contact : *contacts) { - auto contact_obj = serialize_contact(env, contact); - env->CallObjectMethod(our_stack, push, contact_obj); - } - return our_stack; + return jni_utils::run_catching_cxx_exception_or_throws<jobject>(env, [=] { + std::lock_guard lock{util::util_mutex_}; + auto contacts = ptrToContacts(env, thiz); + jclass stack = env->FindClass("java/util/Stack"); + jmethodID init = env->GetMethodID(stack, "<init>", "()V"); + jobject our_stack = env->NewObject(stack, init); + jmethodID push = env->GetMethodID(stack, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (const auto &contact: *contacts) { + auto contact_obj = serialize_contact(env, contact); + env->CallObjectMethod(our_stack, push, contact_obj); + } + return our_stack; + }); } \ No newline at end of file diff --git a/libsession-util/src/main/cpp/contacts.h b/libsession-util/src/main/cpp/contacts.h index c5496a68c8..ecb2cb3749 100644 --- a/libsession-util/src/main/cpp/contacts.h +++ b/libsession-util/src/main/cpp/contacts.h @@ -29,8 +29,7 @@ inline jobject serialize_contact(JNIEnv *env, session::config::contact_info info return returnObj; } -inline session::config::contact_info -deserialize_contact(JNIEnv *env, jobject info, session::config::Contacts *conf) { +inline session::config::contact_info deserialize_contact(JNIEnv *env, jobject info, session::config::Contacts *conf) { jclass contactClass = env->FindClass("network/loki/messenger/libsession_util/util/Contact"); jfieldID getId, getName, getNick, getApproved, getApprovedMe, getBlocked, getUserPic, getPriority, getExpiry, getHidden; diff --git a/libsession-util/src/main/cpp/jni_utils.h b/libsession-util/src/main/cpp/jni_utils.h new file mode 100644 index 0000000000..c9ccd924a6 --- /dev/null +++ b/libsession-util/src/main/cpp/jni_utils.h @@ -0,0 +1,54 @@ +#ifndef SESSION_ANDROID_JNI_UTILS_H +#define SESSION_ANDROID_JNI_UTILS_H + +#include <jni.h> +#include <exception> + +namespace jni_utils { + /** + * Run a C++ function and catch any exceptions, throwing a Java exception if one is caught, + * and returning a default-constructed value of the specified type. + * + * @tparam RetT The return type of the function + * @tparam Func The function type + * @param f The function to run + * @param fallbackRun The function to run if an exception is caught. The optional exception message reference will be passed to this function. + * @return The return value of the function, or the return value of the fallback function if an exception was caught + */ + template<class RetT, class Func, class FallbackRun> + RetT run_catching_cxx_exception_or(Func f, FallbackRun fallbackRun) { + try { + return f(); + } catch (const std::exception &e) { + return fallbackRun(e.what()); + } catch (...) { + return fallbackRun(nullptr); + } + } + + /** + * Run a C++ function and catch any exceptions, throwing a Java exception if one is caught. + * + * @tparam RetT The return type of the function + * @tparam Func The function type + * @param env The JNI environment + * @param f The function to run + * @return The return value of the function, or a default-constructed value of the specified type if an exception was caught + */ + template<class RetT, class Func> + RetT run_catching_cxx_exception_or_throws(JNIEnv *env, Func f) { + return run_catching_cxx_exception_or<RetT>(f, [env](const char *msg) { + jclass exceptionClass = env->FindClass("java/lang/RuntimeException"); + if (msg) { + auto formatted_message = std::string("libsession: C++ exception: ") + msg; + env->ThrowNew(exceptionClass, formatted_message.c_str()); + } else { + env->ThrowNew(exceptionClass, "libsession: Unknown C++ exception"); + } + + return RetT(); + }); + } +} + +#endif //SESSION_ANDROID_JNI_UTILS_H diff --git a/libsession-util/src/main/cpp/user_groups.cpp b/libsession-util/src/main/cpp/user_groups.cpp index 4f2b0e6b85..9754b40891 100644 --- a/libsession-util/src/main/cpp/user_groups.cpp +++ b/libsession-util/src/main/cpp/user_groups.cpp @@ -144,10 +144,11 @@ Java_network_loki_messenger_libsession_1util_UserGroupsConfig_erase__Lnetwork_lo auto conf = ptrToUserGroups(env, thiz); auto communityInfo = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$CommunityGroupInfo"); auto legacyInfo = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$LegacyGroupInfo"); - if (env->GetObjectClass(group_info) == communityInfo) { + auto group_object = env->GetObjectClass(group_info); + if (env->IsSameObject(group_object, communityInfo)) { auto deserialized = deserialize_community_info(env, group_info, conf); conf->erase(deserialized); - } else if (env->GetObjectClass(group_info) == legacyInfo) { + } else if (env->IsSameObject(group_object, legacyInfo)) { auto deserialized = deserialize_legacy_group_info(env, group_info, conf); conf->erase(deserialized); } diff --git a/libsession-util/src/main/cpp/user_profile.cpp b/libsession-util/src/main/cpp/user_profile.cpp index 78b671ef0d..9f5a9e9d36 100644 --- a/libsession-util/src/main/cpp/user_profile.cpp +++ b/libsession-util/src/main/cpp/user_profile.cpp @@ -95,4 +95,58 @@ Java_network_loki_messenger_libsession_1util_UserProfile_getNtsPriority(JNIEnv * std::lock_guard lock{util::util_mutex_}; auto profile = ptrToProfile(env, thiz); return profile->get_nts_priority(); +} + +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_setNtsExpiry(JNIEnv *env, jobject thiz, + jobject expiry_mode) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto expiry = util::deserialize_expiry(env, expiry_mode); + profile->set_nts_expiry(std::chrono::seconds (expiry.second)); +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_getNtsExpiry(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto nts_expiry = profile->get_nts_expiry(); + if (nts_expiry == std::nullopt) { + auto expiry = util::serialize_expiry(env, session::config::expiration_mode::none, std::chrono::seconds(0)); + return expiry; + } + auto expiry = util::serialize_expiry(env, session::config::expiration_mode::after_send, std::chrono::seconds(*nts_expiry)); + return expiry; +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_getCommunityMessageRequests( + JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto blinded_msg_requests = profile->get_blinded_msgreqs(); + if (blinded_msg_requests.has_value()) { + return *blinded_msg_requests; + } + return true; +} + +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_setCommunityMessageRequests( + JNIEnv *env, jobject thiz, jboolean blocks) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + profile->set_blinded_msgreqs(std::optional{(bool)blocks}); +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_isBlockCommunityMessageRequestsSet( + JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + return profile->get_blinded_msgreqs().has_value(); } \ No newline at end of file diff --git a/libsession-util/src/main/cpp/util.cpp b/libsession-util/src/main/cpp/util.cpp index 69469eac1e..602580f04a 100644 --- a/libsession-util/src/main/cpp/util.cpp +++ b/libsession-util/src/main/cpp/util.cpp @@ -96,14 +96,25 @@ namespace util { jclass object_class = env->GetObjectClass(expiry_mode); - if (object_class == after_read) { + if (env->IsSameObject(object_class, after_read)) { return std::pair(session::config::expiration_mode::after_read, env->GetLongField(expiry_mode, duration_seconds)); - } else if (object_class == after_send) { + } else if (env->IsSameObject(object_class, after_send)) { return std::pair(session::config::expiration_mode::after_send, env->GetLongField(expiry_mode, duration_seconds)); } return std::pair(session::config::expiration_mode::none, 0); } + jobject build_string_stack(JNIEnv* env, std::vector<std::string> to_add) { + jclass stack_class = env->FindClass("java/util/Stack"); + jmethodID constructor = env->GetMethodID(stack_class,"<init>", "()V"); + jmethodID add = env->GetMethodID(stack_class, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + jobject our_stack = env->NewObject(stack_class, constructor); + for (std::basic_string_view<char> string: to_add) { + env->CallObjectMethod(our_stack, add, env->NewStringUTF(string.data())); + } + return our_stack; + } + } extern "C" diff --git a/libsession-util/src/main/cpp/util.h b/libsession-util/src/main/cpp/util.h index 9348e8bd7e..0d5189b9c9 100644 --- a/libsession-util/src/main/cpp/util.h +++ b/libsession-util/src/main/cpp/util.h @@ -19,6 +19,7 @@ namespace util { session::config::community deserialize_base_community(JNIEnv *env, jobject base_community); jobject serialize_expiry(JNIEnv *env, const session::config::expiration_mode& mode, const std::chrono::seconds& time_seconds); std::pair<session::config::expiration_mode, long> deserialize_expiry(JNIEnv *env, jobject expiry_mode); + jobject build_string_stack(JNIEnv* env, std::vector<std::string> to_add); } #endif \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt index 52fb541d7d..befd0d6d43 100644 --- a/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt @@ -4,11 +4,13 @@ import network.loki.messenger.libsession_util.util.BaseCommunityInfo import network.loki.messenger.libsession_util.util.ConfigPush import network.loki.messenger.libsession_util.util.Contact import network.loki.messenger.libsession_util.util.Conversation +import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.GroupInfo import network.loki.messenger.libsession_util.util.UserPic import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import java.util.Stack sealed class ConfigBase(protected val /* yucky */ pointer: Long) { @@ -44,13 +46,13 @@ sealed class ConfigBase(protected val /* yucky */ pointer: Long) { external fun dump(): ByteArray external fun encryptionDomain(): String external fun confirmPushed(seqNo: Long, newHash: String) - external fun merge(toMerge: Array<Pair<String,ByteArray>>): Int + external fun merge(toMerge: Array<Pair<String,ByteArray>>): Stack<String> external fun currentHashes(): List<String> external fun configNamespace(): Int // Singular merge - external fun merge(toMerge: Pair<String,ByteArray>): Int + external fun merge(toMerge: Pair<String,ByteArray>): Stack<String> external fun free() @@ -126,6 +128,11 @@ class UserProfile(pointer: Long) : ConfigBase(pointer) { external fun setPic(userPic: UserPic) external fun setNtsPriority(priority: Int) external fun getNtsPriority(): Int + external fun setNtsExpiry(expiryMode: ExpiryMode) + external fun getNtsExpiry(): ExpiryMode + external fun getCommunityMessageRequests(): Boolean + external fun setCommunityMessageRequests(blocks: Boolean) + external fun isBlockCommunityMessageRequestsSet(): Boolean } class ConversationVolatileConfig(pointer: Long): ConfigBase(pointer) { diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt index 58e98a4392..9761ce5083 100644 --- a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt @@ -1,7 +1,18 @@ package network.loki.messenger.libsession_util.util +import kotlin.time.Duration.Companion.seconds + sealed class ExpiryMode(val expirySeconds: Long) { object NONE: ExpiryMode(0) - class AfterSend(seconds: Long): ExpiryMode(seconds) - class AfterRead(seconds: Long): ExpiryMode(seconds) -} \ No newline at end of file + data class Legacy(private val seconds: Long = 0L): ExpiryMode(seconds) + data class AfterSend(private val seconds: Long = 0L): ExpiryMode(seconds) + data class AfterRead(private val seconds: Long = 0L): ExpiryMode(seconds) + + val duration get() = expirySeconds.seconds + + val expiryMillis get() = expirySeconds * 1000L + + fun coerceSendToRead(coerce: Boolean = true) = if (coerce && this is AfterSend) AfterRead(expirySeconds) else this +} + +fun afterSend(seconds: Long) = seconds.takeIf { it > 0 }?.let(ExpiryMode::AfterSend) ?: ExpiryMode.NONE \ No newline at end of file diff --git a/libsession/build.gradle b/libsession/build.gradle index 045648e090..55146823ec 100644 --- a/libsession/build.gradle +++ b/libsession/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.library' id 'kotlin-android' + id 'kotlinx-serialization' } android { @@ -20,7 +21,7 @@ dependencies { implementation project(":libsignal") implementation project(":libsession-util") implementation project(":liblazysodium") - implementation "net.java.dev.jna:jna:5.8.0@aar" + implementation "net.java.dev.jna:jna:5.12.1@aar" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "androidx.core:core-ktx:$coreVersion" implementation "androidx.appcompat:appcompat:$appcompatVersion" @@ -28,8 +29,8 @@ dependencies { implementation "com.google.android.material:material:$materialVersion" implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.google.dagger:hilt-android:$daggerVersion" - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' implementation "com.github.bumptech.glide:glide:$glideVersion" implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1' implementation 'com.annimon:stream:1.1.8' @@ -41,10 +42,11 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion" implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" testImplementation "junit:junit:$junitVersion" testImplementation 'org.assertj:assertj-core:3.11.1' - testImplementation "org.mockito:mockito-inline:4.0.0" + testImplementation "org.mockito:mockito-inline:4.11.0" testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" testImplementation "androidx.test:core:$testCoreVersion" testImplementation "androidx.arch.core:core-testing:2.1.0" diff --git a/libsession/src/main/java/org/session/libsession/avatars/AvatarHelper.java b/libsession/src/main/java/org/session/libsession/avatars/AvatarHelper.java index 1588289017..cc0909a592 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/AvatarHelper.java +++ b/libsession/src/main/java/org/session/libsession/avatars/AvatarHelper.java @@ -8,9 +8,11 @@ import androidx.annotation.Nullable; import com.annimon.stream.Stream; import org.session.libsession.utilities.Address; +import org.session.libsignal.utilities.Log; import java.io.File; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; @@ -22,9 +24,9 @@ public class AvatarHelper { private static final String AVATAR_DIRECTORY = "avatars"; public static InputStream getInputStreamFor(@NonNull Context context, @NonNull Address address) - throws IOException + throws FileNotFoundException { - return new FileInputStream(getAvatarFile(context, address)); + return new FileInputStream(getAvatarFile(context, address)); } public static List<File> getAvatarFiles(@NonNull Context context) { diff --git a/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt b/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt index 0fcbe36e90..916e9112de 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt +++ b/libsession/src/main/java/org/session/libsession/avatars/PlaceholderAvatarPhoto.kt @@ -1,13 +1,10 @@ package org.session.libsession.avatars -import android.content.Context import com.bumptech.glide.load.Key import java.security.MessageDigest -class PlaceholderAvatarPhoto(val context: Context, - val hashString: String, +class PlaceholderAvatarPhoto(val hashString: String, val displayName: String): Key { - override fun updateDiskCacheKey(messageDigest: MessageDigest) { messageDigest.update(hashString.encodeToByteArray()) messageDigest.update(displayName.encodeToByteArray()) diff --git a/libsession/src/main/java/org/session/libsession/avatars/ProfileContactPhoto.java b/libsession/src/main/java/org/session/libsession/avatars/ProfileContactPhoto.java index f8675b031f..76a3449625 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/ProfileContactPhoto.java +++ b/libsession/src/main/java/org/session/libsession/avatars/ProfileContactPhoto.java @@ -8,6 +8,7 @@ import androidx.annotation.Nullable; import org.session.libsession.utilities.Address; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.security.MessageDigest; @@ -24,7 +25,7 @@ public class ProfileContactPhoto implements ContactPhoto { } @Override - public InputStream openInputStream(Context context) throws IOException { + public InputStream openInputStream(Context context) throws FileNotFoundException { return AvatarHelper.getInputStreamFor(context, address); } diff --git a/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java b/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java index f78089e25e..2920b4b1ce 100644 --- a/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java +++ b/libsession/src/main/java/org/session/libsession/avatars/ResourceContactPhoto.java @@ -8,6 +8,7 @@ import android.graphics.drawable.LayerDrawable; import android.widget.ImageView; import androidx.annotation.DrawableRes; +import androidx.appcompat.content.res.AppCompatResources; import com.amulyakhare.textdrawable.TextDrawable; import com.makeramen.roundedimageview.RoundedDrawable; @@ -31,7 +32,7 @@ public class ResourceContactPhoto implements FallbackContactPhoto { @Override public Drawable asDrawable(Context context, int color, boolean inverted) { Drawable background = TextDrawable.builder().buildRound(" ", inverted ? Color.WHITE : color); - RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(context.getResources().getDrawable(resourceId)); + RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(AppCompatResources.getDrawable(context, resourceId)); foreground.setScaleType(ImageView.ScaleType.CENTER_CROP); @@ -39,8 +40,10 @@ public class ResourceContactPhoto implements FallbackContactPhoto { foreground.setColorFilter(color, PorterDuff.Mode.SRC_ATOP); } - Drawable gradient = context.getResources().getDrawable(ThemeUtil.isDarkTheme(context) ? R.drawable.avatar_gradient_dark - : R.drawable.avatar_gradient_light); + Drawable gradient = AppCompatResources.getDrawable( + context, + ThemeUtil.isDarkTheme(context) ? R.drawable.avatar_gradient_dark : R.drawable.avatar_gradient_light + ); return new ExpandingLayerDrawable(new Drawable[] {background, foreground, gradient}); } diff --git a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt b/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt index 24324ccb7e..52c0b8c7a3 100644 --- a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt +++ b/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt @@ -24,7 +24,7 @@ interface MessageDataProvider { fun deleteMessage(messageID: Long, isSms: Boolean) fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) fun updateMessageAsDeleted(timestamp: Long, author: String): Long? - fun getServerHashForMessage(messageID: Long): String? + fun getServerHashForMessage(messageID: Long, mms: Boolean): String? fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? fun getAttachmentStream(attachmentId: Long): SessionServiceAttachmentStream? fun getAttachmentPointer(attachmentId: Long): SessionServiceAttachmentPointer? diff --git a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt index 4fff833835..260e254fe7 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -10,6 +10,7 @@ import org.session.libsession.messaging.jobs.AttachmentUploadJob import org.session.libsession.messaging.jobs.Job import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse @@ -42,6 +43,7 @@ interface StorageProtocol { fun getUserProfile(): Profile fun setProfileAvatar(recipient: Recipient, profileAvatar: String?) fun setProfilePicture(recipient: Recipient, newProfilePicture: String?, newProfileKey: ByteArray?) + fun setBlocksCommunityMessageRequests(recipient: Recipient, blocksMessageRequests: Boolean) fun setUserProfilePicture(newProfilePicture: String?, newProfileKey: ByteArray?) fun clearUserPic() // Signal @@ -112,22 +114,24 @@ interface StorageProtocol { */ fun persistAttachments(messageID: Long, attachments: List<Attachment>): List<Long> fun getAttachmentsForMessage(messageID: Long): List<DatabaseAttachment> - fun getMessageIdInDatabase(timestamp: Long, author: String): Long? // TODO: This is a weird name + fun getMessageIdInDatabase(timestamp: Long, author: String): Pair<Long, Boolean>? // TODO: This is a weird name fun updateSentTimestamp(messageID: Long, isMms: Boolean, openGroupSentTimestamp: Long, threadId: Long) fun markAsResyncing(timestamp: Long, author: String) fun markAsSyncing(timestamp: Long, author: String) fun markAsSending(timestamp: Long, author: String) fun markAsSent(timestamp: Long, author: String) + fun markAsSentToCommunity(threadID: Long, messageID: Long) fun markUnidentified(timestamp: Long, author: String) + fun markUnidentifiedInCommunity(threadID: Long, messageID: Long) fun markAsSyncFailed(timestamp: Long, author: String, error: Exception) fun markAsSentFailed(timestamp: Long, author: String, error: Exception) fun clearErrorMessage(messageID: Long) - fun setMessageServerHash(messageID: Long, serverHash: String) + fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) // Closed Groups fun getGroup(groupID: String): GroupRecord? fun createGroup(groupID: String, title: String?, members: List<Address>, avatar: SignalServiceAttachmentPointer?, relay: String?, admins: List<Address>, formationTimestamp: Long) - fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair) + fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<String, Boolean>, formationTimestamp: Long, encryptionKeyPair: ECKeyPair, expirationTimer: Int) fun updateGroupConfig(groupPublicKey: String) fun isGroupActive(groupPublicKey: String): Boolean fun setActive(groupID: String, value: Boolean) @@ -150,7 +154,6 @@ interface StorageProtocol { fun getLatestClosedGroupEncryptionKeyPair(groupPublicKey: String): ECKeyPair? fun updateFormationTimestamp(groupID: String, formationTimestamp: Long) fun updateTimestampUpdated(groupID: String, updatedTimestamp: Long) - fun setExpirationTimer(address: String, duration: Int) // Groups fun getAllGroups(includeInactive: Boolean): List<GroupRecord> @@ -158,7 +161,6 @@ interface StorageProtocol { // Settings fun setProfileSharing(address: Address, value: Boolean) - // Thread fun getOrCreateThreadIdFor(address: Address): Long fun getThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?, createThread: Boolean): Long? @@ -175,6 +177,8 @@ interface StorageProtocol { fun isPinned(threadID: Long): Boolean fun deleteConversation(threadID: Long) fun setThreadDate(threadId: Long, newDate: Long) + fun getLastLegacyRecipient(threadRecipient: String): String? + fun setLastLegacyRecipient(threadRecipient: String, senderRecipient: String?) // Contacts fun getContactWithSessionID(sessionID: String): Contact? @@ -182,7 +186,7 @@ interface StorageProtocol { fun setContact(contact: Contact) fun getRecipientForThread(threadId: Long): Recipient? fun getRecipientSettings(address: Address): RecipientSettings? - fun addLibSessionContacts(contacts: List<LibSessionContact>) + fun addLibSessionContacts(contacts: List<LibSessionContact>, timestamp: Long) fun addContacts(contacts: List<ConfigurationMessage.Contact>) // Attachments @@ -223,9 +227,18 @@ interface StorageProtocol { fun setBlocked(recipients: Iterable<Recipient>, isBlocked: Boolean, fromConfigUpdate: Boolean = false) fun setRecipientHash(recipient: Recipient, recipientHash: String?) fun blockedContacts(): List<Recipient> + fun getExpirationConfiguration(threadId: Long): ExpirationConfiguration? + fun setExpirationConfiguration(config: ExpirationConfiguration) + fun getExpiringMessages(messageIds: List<Long> = emptyList()): List<Pair<Long, Long>> + fun updateDisappearingState( + messageSender: String, + threadID: Long, + disappearingState: Recipient.DisappearingState + ) // Shared configs - fun notifyConfigUpdates(forConfigObject: ConfigBase) + fun notifyConfigUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean fun canPerformConfigChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean + fun isCheckingCommunityRequests(): Boolean } diff --git a/libsession/src/main/java/org/session/libsession/messaging/LastSentTimestampCache.kt b/libsession/src/main/java/org/session/libsession/messaging/LastSentTimestampCache.kt new file mode 100644 index 0000000000..a41ba60c80 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/LastSentTimestampCache.kt @@ -0,0 +1,9 @@ +package org.session.libsession.messaging + +interface LastSentTimestampCache { + fun getTimestamp(threadId: Long): Long? + fun submitTimestamp(threadId: Long, timestamp: Long) + fun delete(threadId: Long, timestamps: List<Long>) + fun delete(threadId: Long, timestamp: Long) = delete(threadId, listOf(timestamp)) + fun refresh(threadId: Long) +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt b/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt index 0437196772..e4f15b2114 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -5,13 +5,16 @@ import com.goterl.lazysodium.utils.KeyPair import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.StorageProtocol import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.Device class MessagingModuleConfiguration( val context: Context, val storage: StorageProtocol, + val device: Device, val messageDataProvider: MessageDataProvider, val getUserED25519KeyPair: () -> KeyPair?, - val configFactory: ConfigFactoryProtocol + val configFactory: ConfigFactoryProtocol, + val lastSentTimestampCache: LastSentTimestampCache ) { companion object { diff --git a/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt b/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt index 92ff9190c5..7161d070aa 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/contacts/Contact.kt @@ -77,7 +77,7 @@ class Contact(val sessionID: String) { companion object { fun contextForRecipient(recipient: Recipient): ContactContext { - return if (recipient.isOpenGroupRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR + return if (recipient.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR } } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index b9eaf8d50d..6aae0c6c9b 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -35,7 +35,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) override val maxFailureCount: Int = 2 companion object { - val KEY: String = "AttachmentDownloadJob" + const val KEY: String = "AttachmentDownloadJob" // Keys used for database storage private val ATTACHMENT_ID_KEY = "attachment_id" @@ -89,19 +89,21 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) } val threadRecipient = storage.getRecipientForThread(threadID) - val sender = if (messageDataProvider.isMmsOutgoing(databaseMessageID)) { + val selfSend = messageDataProvider.isMmsOutgoing(databaseMessageID) + val sender = if (selfSend) { storage.getUserPublicKey() } else { messageDataProvider.getIndividualRecipientForMms(databaseMessageID)?.address?.serialize() } val contact = sender?.let { storage.getContactWithSessionID(it) } - if (threadRecipient == null || sender == null || contact == null) { + if (threadRecipient == null || sender == null || (contact == null && !selfSend)) { handleFailure(Error.NoSender, null) return } - if (!threadRecipient.isGroupRecipient && (!contact.isTrusted && storage.getUserPublicKey() != sender)) { + if (!threadRecipient.isGroupRecipient && contact?.isTrusted != true && storage.getUserPublicKey() != sender) { // if we aren't receiving a group message, a message from ourselves (self-send) and the contact sending is not trusted: // do not continue, but do not fail + handleFailure(Error.NoSender, null) return } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt index 20442e5594..f284f2539d 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/BackgroundGroupAddJob.kt @@ -37,6 +37,7 @@ class BackgroundGroupAddJob(val joinUrl: String): Job { delegate?.handleJobFailed(this, dispatcherName, DuplicateGroupException()) return } + storage.addOpenGroup(openGroup.joinUrl()) storage.onOpenGroupAdded(openGroup.server, openGroup.room) } catch (e: Exception) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt index 41301e13b8..02297399b1 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt @@ -34,6 +34,7 @@ import org.session.libsession.utilities.SSKEnvironment import org.session.libsignal.protos.UtilProtos import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log +import kotlin.math.max data class MessageReceiveParameters( val data: ByteArray, @@ -146,7 +147,7 @@ class BatchMessageReceiveJob( // The LinkedHashMap should preserve insertion order val messageIds = linkedMapOf<Long, Pair<Boolean, Boolean>>() val myLastSeen = storage.getLastSeen(threadId) - var newLastSeen = if (myLastSeen == -1L) 0 else myLastSeen + var newLastSeen = myLastSeen.takeUnless { it == -1L } ?: 0 messages.forEach { (parameters, message, proto) -> try { when (message) { @@ -162,18 +163,14 @@ class BatchMessageReceiveJob( IdPrefix.BLINDED, it.publicKey.asBytes ).hexString } - val sentTimestamp = message.sentTimestamp!! if (message.sender == localUserPublicKey || isUserBlindedSender) { - if (sentTimestamp > newLastSeen) { - newLastSeen = - sentTimestamp // use sent timestamp here since that is technically the last one we have - } + // use sent timestamp here since that is technically the last one we have + newLastSeen = max(newLastSeen, message.sentTimestamp!!) } - val messageId = MessageReceiver.handleVisibleMessage( - message, proto, openGroupID, threadId, + val messageId = MessageReceiver.handleVisibleMessage(message, proto, openGroupID, + threadId, runThreadUpdate = false, - runProfileUpdate = true - ) + runProfileUpdate = true) if (messageId != null && message.reaction == null) { messageIds[messageId] = Pair( @@ -216,9 +213,7 @@ class BatchMessageReceiveJob( // last seen will be the current last seen if not changed (re-computes the read counts for thread record) // might have been updated from a different thread at this point val currentLastSeen = storage.getLastSeen(threadId).let { if (it == -1L) 0 else it } - if (currentLastSeen > newLastSeen) { - newLastSeen = currentLastSeen - } + newLastSeen = max(newLastSeen, currentLastSeen) if (newLastSeen > 0 || currentLastSeen == 0L) { storage.markConversationAsRead(threadId, newLastSeen, force = true) } @@ -247,12 +242,12 @@ class BatchMessageReceiveJob( private fun handleSuccess(dispatcherName: String) { Log.i(TAG, "Completed processing of ${messages.size} messages (id: $id)") - this.delegate?.handleJobSucceeded(this, dispatcherName) + delegate?.handleJobSucceeded(this, dispatcherName) } private fun handleFailure(dispatcherName: String) { Log.i(TAG, "Handling failure of ${failures.size} messages (${messages.size - failures.size} processed successfully) (id: $id)") - this.delegate?.handleJobFailed(this, dispatcherName, Exception("One or more jobs resulted in failure")) + delegate?.handleJobFailed(this, dispatcherName, Exception("One or more jobs resulted in failure")) } override fun serialize(): Data { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index b437808f98..71d62bc72f 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -144,7 +144,7 @@ class JobQueue : JobDelegate { } } else -> { - throw IllegalStateException("Unexpected job type.") + throw IllegalStateException("Unexpected job type: ${job.getFactoryKey()}") } } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt index be58544970..79c30f67e8 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/NotifyPNServerJob.kt @@ -3,12 +3,11 @@ package org.session.libsession.messaging.jobs import com.esotericsoftware.kryo.Kryo import com.esotericsoftware.kryo.io.Input import com.esotericsoftware.kryo.io.Output -import nl.komponents.kovenant.functional.map import okhttp3.MediaType import okhttp3.Request import okhttp3.RequestBody import org.session.libsession.messaging.jobs.Job.Companion.MAX_BUFFER_SIZE -import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationAPI +import org.session.libsession.messaging.sending_receiving.notifications.Server import org.session.libsession.messaging.utilities.Data import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.SnodeMessage @@ -31,23 +30,27 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job { } override suspend fun execute(dispatcherName: String) { - val server = PushNotificationAPI.server + val server = Server.LEGACY val parameters = mapOf( "data" to message.data, "send_to" to message.recipient ) - val url = "${server}/notify" + val url = "${server.url}/notify" val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) - val request = Request.Builder().url(url).post(body) + val request = Request.Builder().url(url).post(body).build() retryIfNeeded(4) { - OnionRequestAPI.sendOnionRequest(request.build(), server, PushNotificationAPI.serverPublicKey, Version.V2).map { response -> - val code = response.info["code"] as? Int - if (code == null || code == 0) { - Log.d("Loki", "Couldn't notify PN server due to error: ${response.info["message"] as? String ?: "null"}.") + OnionRequestAPI.sendOnionRequest( + request, + server.url, + server.publicKey, + Version.V2 + ) success { response -> + when (response.code) { + null, 0 -> Log.d("NotifyPNServerJob", "Couldn't notify PN server due to error: ${response.message}.") } - }.fail { exception -> - Log.d("Loki", "Couldn't notify PN server due to error: $exception.") + } fail { exception -> + Log.d("NotifyPNServerJob", "Couldn't notify PN server due to error: $exception.") } - }.success { + } success { handleSuccess(dispatcherName) - }. fail { + } fail { handleFailure(dispatcherName, it) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt index 333c87ba78..271549c410 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/OpenGroupDeleteJob.kt @@ -22,7 +22,7 @@ class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val th override suspend fun execute(dispatcherName: String) { val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider val numberToDelete = messageServerIds.size - Log.d(TAG, "Deleting $numberToDelete messages") + Log.d(TAG, "About to attempt to delete $numberToDelete messages") // FIXME: This entire process should probably run in a transaction (with the attachment deletion happening only if it succeeded) try { @@ -42,6 +42,7 @@ class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val th delegate?.handleJobSucceeded(this, dispatcherName) } catch (e: Exception) { + Log.w(TAG, "OpenGroupDeleteJob failed: $e") delegate?.handleJobFailed(this, dispatcherName, e) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/mentions/MentionsManager.kt b/libsession/src/main/java/org/session/libsession/messaging/mentions/MentionsManager.kt index fd16061e67..c3537a334a 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/mentions/MentionsManager.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/mentions/MentionsManager.kt @@ -2,7 +2,7 @@ package org.session.libsession.messaging.mentions import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact -import java.util.* +import java.util.Locale object MentionsManager { var userPublicKeyCache = mutableMapOf<Long, Set<String>>() // Thread ID to set of user hex encoded public keys diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt index f30c1b9168..faad7aeebf 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/Destination.kt @@ -43,14 +43,14 @@ sealed class Destination { val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupID).toHexString() ClosedGroup(groupPublicKey) } - address.isOpenGroup -> { + address.isCommunity -> { val storage = MessagingModuleConfiguration.shared.storage val threadID = storage.getThreadId(address)!! storage.getOpenGroup(threadID)?.let { OpenGroup(roomToken = it.room, server = it.server, fileIds = fileIds) } ?: throw Exception("Missing open group for thread with ID: $threadID.") } - address.isOpenGroupInbox -> { + address.isCommunityInbox -> { val groupInboxId = GroupUtil.getDecodedGroupID(address.serialize()).split("!") OpenGroupInbox( groupInboxId.dropLast(2).joinToString("!"), diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/ExpirationConfiguration.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/ExpirationConfiguration.kt new file mode 100644 index 0000000000..8e3ab18b22 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/ExpirationConfiguration.kt @@ -0,0 +1,20 @@ +package org.session.libsession.messaging.messages + +import network.loki.messenger.libsession_util.util.ExpiryMode + +data class ExpirationConfiguration( + val threadId: Long = -1, + val expiryMode: ExpiryMode = ExpiryMode.NONE, + val updatedTimestampMs: Long = 0 +) { + val isEnabled = expiryMode.expirySeconds > 0 + + companion object { + val isNewConfigEnabled = true + } +} + +data class ExpirationDatabaseMetadata( + val threadId: Long = -1, + val updatedTimestampMs: Long +) diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt index dd1d5f1852..5a7ecaedaf 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/Message.kt @@ -1,11 +1,14 @@ package org.session.libsession.messaging.messages import com.google.protobuf.ByteString +import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.utilities.GroupUtil import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType abstract class Message { var id: Long? = null @@ -18,8 +21,14 @@ abstract class Message { var groupPublicKey: String? = null var openGroupServerMessageID: Long? = null var serverHash: String? = null + var specifiedTtl: Long? = null - open val ttl: Long = 14 * 24 * 60 * 60 * 1000 + var expiryMode: ExpiryMode = ExpiryMode.NONE + + open val coerceDisappearAfterSendToRead = false + + open val defaultTtl: Long = 14 * 24 * 60 * 60 * 1000 + open val ttl: Long get() = specifiedTtl ?: defaultTtl open val isSelfSendValid: Boolean = false companion object { @@ -33,22 +42,54 @@ abstract class Message { } } - open fun isValid(): Boolean { - val sentTimestamp = sentTimestamp - if (sentTimestamp != null && sentTimestamp <= 0) { return false } - val receivedTimestamp = receivedTimestamp - if (receivedTimestamp != null && receivedTimestamp <= 0) { return false } - return sender != null && recipient != null - } + open fun isValid(): Boolean = + sentTimestamp?.let { it > 0 } != false + && receivedTimestamp?.let { it > 0 } != false + && sender != null + && recipient != null abstract fun toProto(): SignalServiceProtos.Content? - fun setGroupContext(dataMessage: SignalServiceProtos.DataMessage.Builder) { - val groupProto = SignalServiceProtos.GroupContext.newBuilder() - val groupID = GroupUtil.doubleEncodeGroupID(recipient!!) - groupProto.id = ByteString.copyFrom(GroupUtil.getDecodedGroupIDAsData(groupID)) - groupProto.type = SignalServiceProtos.GroupContext.Type.DELIVER - dataMessage.group = groupProto.build() + fun SignalServiceProtos.DataMessage.Builder.setGroupContext() { + group = SignalServiceProtos.GroupContext.newBuilder().apply { + id = GroupUtil.doubleEncodeGroupID(recipient!!).let(GroupUtil::getDecodedGroupIDAsData).let(ByteString::copyFrom) + type = SignalServiceProtos.GroupContext.Type.DELIVER + }.build() } -} \ No newline at end of file + fun SignalServiceProtos.Content.Builder.applyExpiryMode() = apply { + expirationTimer = expiryMode.expirySeconds.toInt() + expirationType = when (expiryMode) { + is ExpiryMode.AfterSend -> ExpirationType.DELETE_AFTER_SEND + is ExpiryMode.AfterRead -> ExpirationType.DELETE_AFTER_READ + else -> ExpirationType.UNKNOWN + } + } +} + +inline fun <reified M: Message> M.copyExpiration(proto: SignalServiceProtos.Content): M = apply { + (proto.takeIf { it.hasExpirationTimer() }?.expirationTimer ?: proto.dataMessage?.expireTimer)?.let { duration -> + expiryMode = when (proto.expirationType.takeIf { duration > 0 }) { + ExpirationType.DELETE_AFTER_SEND -> ExpiryMode.AfterSend(duration.toLong()) + ExpirationType.DELETE_AFTER_READ -> ExpiryMode.AfterRead(duration.toLong()) + else -> ExpiryMode.NONE + } + } +} + +fun SignalServiceProtos.Content.expiryMode(): ExpiryMode = + (takeIf { it.hasExpirationTimer() }?.expirationTimer ?: dataMessage?.expireTimer)?.let { duration -> + when (expirationType.takeIf { duration > 0 }) { + ExpirationType.DELETE_AFTER_SEND -> ExpiryMode.AfterSend(duration.toLong()) + ExpirationType.DELETE_AFTER_READ -> ExpiryMode.AfterRead(duration.toLong()) + else -> ExpiryMode.NONE + } + } ?: ExpiryMode.NONE + +/** + * Apply ExpiryMode from the current setting. + */ +inline fun <reified M: Message> M.applyExpiryMode(thread: Long): M = apply { + val storage = MessagingModuleConfiguration.shared.storage + expiryMode = storage.getExpirationConfiguration(thread)?.expiryMode?.coerceSendToRead(coerceDisappearAfterSendToRead) ?: ExpiryMode.NONE +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt index 0befa5f4a3..f678271030 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/CallMessage.kt @@ -1,5 +1,7 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.messaging.messages.applyExpiryMode +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.* import org.session.libsignal.utilities.Log @@ -12,12 +14,13 @@ class CallMessage(): ControlMessage() { var sdpMids: List<String> = listOf() var callId: UUID? = null + override val coerceDisappearAfterSendToRead = true override val isSelfSendValid: Boolean get() = type in arrayOf(ANSWER, END_CALL) - override val ttl: Long = 300000L // 5m + override val defaultTtl: Long = 300000L // 5m override fun isValid(): Boolean = super.isValid() && type != null && callId != null - && (!sdps.isNullOrEmpty() || type in listOf(END_CALL, PRE_OFFER)) + && (sdps.isNotEmpty() || type in listOf(END_CALL, PRE_OFFER)) constructor(type: SignalServiceProtos.CallMessage.Type, sdps: List<String>, @@ -64,7 +67,8 @@ class CallMessage(): ControlMessage() { val sdpMLineIndexes = callMessageProto.sdpMLineIndexesList val sdpMids = callMessageProto.sdpMidsList val callId = UUID.fromString(callMessageProto.uuid) - return CallMessage(type,sdps, sdpMLineIndexes, sdpMids, callId) + return CallMessage(type, sdps, sdpMLineIndexes, sdpMids, callId) + .copyExpiration(proto) } } @@ -82,9 +86,8 @@ class CallMessage(): ControlMessage() { .setUuid(callId!!.toString()) return SignalServiceProtos.Content.newBuilder() - .setCallMessage( - callMessage - ) + .applyExpiryMode() + .setCallMessage(callMessage) .build() } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ClosedGroupControlMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ClosedGroupControlMessage.kt index c7aa03a7b2..101baac2d0 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ClosedGroupControlMessage.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ClosedGroupControlMessage.kt @@ -6,15 +6,22 @@ import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos.DataMessage -import org.session.libsignal.utilities.removingIdPrefixIfNeeded -import org.session.libsignal.utilities.toHexString +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.ClosedGroupControlMessage.Type.ENCRYPTION_KEY_PAIR +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.ClosedGroupControlMessage.Type.MEMBERS_ADDED +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.ClosedGroupControlMessage.Type.MEMBERS_REMOVED +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.ClosedGroupControlMessage.Type.MEMBER_LEFT +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.ClosedGroupControlMessage.Type.NAME_CHANGE +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.ClosedGroupControlMessage.Type.NEW import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.removingIdPrefixIfNeeded +import org.session.libsignal.utilities.toHexString class ClosedGroupControlMessage() : ControlMessage() { var kind: Kind? = null + var groupID: String? = null - override val ttl: Long get() { + override val defaultTtl: Long get() { return when (kind) { is Kind.EncryptionKeyPair -> 14 * 24 * 60 * 60 * 1000 else -> 14 * 24 * 60 * 60 * 1000 @@ -76,50 +83,30 @@ class ClosedGroupControlMessage() : ControlMessage() { companion object { const val TAG = "ClosedGroupControlMessage" - fun fromProto(proto: SignalServiceProtos.Content): ClosedGroupControlMessage? { - if (!proto.hasDataMessage() || !proto.dataMessage.hasClosedGroupControlMessage()) return null - val closedGroupControlMessageProto = proto.dataMessage!!.closedGroupControlMessage!! - val kind: Kind - when (closedGroupControlMessageProto.type!!) { - DataMessage.ClosedGroupControlMessage.Type.NEW -> { - val publicKey = closedGroupControlMessageProto.publicKey ?: return null - val name = closedGroupControlMessageProto.name ?: return null - val encryptionKeyPairAsProto = closedGroupControlMessageProto.encryptionKeyPair ?: return null - val expirationTimer = closedGroupControlMessageProto.expirationTimer - try { - val encryptionKeyPair = ECKeyPair(DjbECPublicKey(encryptionKeyPairAsProto.publicKey.toByteArray()), - DjbECPrivateKey(encryptionKeyPairAsProto.privateKey.toByteArray())) - kind = Kind.New(publicKey, name, encryptionKeyPair, closedGroupControlMessageProto.membersList, closedGroupControlMessageProto.adminsList, expirationTimer) - } catch (e: Exception) { - Log.w(TAG, "Couldn't parse key pair from proto: $encryptionKeyPairAsProto.") - return null - } - } - DataMessage.ClosedGroupControlMessage.Type.ENCRYPTION_KEY_PAIR -> { - val publicKey = closedGroupControlMessageProto.publicKey - val wrappers = closedGroupControlMessageProto.wrappersList.mapNotNull { KeyPairWrapper.fromProto(it) } - kind = Kind.EncryptionKeyPair(publicKey, wrappers) - } - DataMessage.ClosedGroupControlMessage.Type.NAME_CHANGE -> { - val name = closedGroupControlMessageProto.name ?: return null - kind = Kind.NameChange(name) - } - DataMessage.ClosedGroupControlMessage.Type.MEMBERS_ADDED -> { - kind = Kind.MembersAdded(closedGroupControlMessageProto.membersList) - } - DataMessage.ClosedGroupControlMessage.Type.MEMBERS_REMOVED -> { - kind = Kind.MembersRemoved(closedGroupControlMessageProto.membersList) - } - DataMessage.ClosedGroupControlMessage.Type.MEMBER_LEFT -> { - kind = Kind.MemberLeft() - } - } - return ClosedGroupControlMessage(kind) - } + fun fromProto(proto: SignalServiceProtos.Content): ClosedGroupControlMessage? = + proto.takeIf { it.hasDataMessage() }?.dataMessage + ?.takeIf { it.hasClosedGroupControlMessage() }?.closedGroupControlMessage + ?.run { + when (type) { + NEW -> takeIf { it.hasPublicKey() && it.hasEncryptionKeyPair() && it.hasName() }?.let { + ECKeyPair( + DjbECPublicKey(encryptionKeyPair.publicKey.toByteArray()), + DjbECPrivateKey(encryptionKeyPair.privateKey.toByteArray()) + ).let { Kind.New(publicKey, name, it, membersList, adminsList, expirationTimer) } + } + ENCRYPTION_KEY_PAIR -> Kind.EncryptionKeyPair(publicKey, wrappersList.mapNotNull(KeyPairWrapper::fromProto)) + NAME_CHANGE -> takeIf { it.hasName() }?.let { Kind.NameChange(name) } + MEMBERS_ADDED -> Kind.MembersAdded(membersList) + MEMBERS_REMOVED -> Kind.MembersRemoved(membersList) + MEMBER_LEFT -> Kind.MemberLeft() + else -> null + }?.let(::ClosedGroupControlMessage) + } } - internal constructor(kind: Kind?) : this() { + internal constructor(kind: Kind?, groupID: String? = null) : this() { this.kind = kind + this.groupID = groupID } override fun toProto(): SignalServiceProtos.Content? { @@ -132,45 +119,44 @@ class ClosedGroupControlMessage() : ControlMessage() { val closedGroupControlMessage: DataMessage.ClosedGroupControlMessage.Builder = DataMessage.ClosedGroupControlMessage.newBuilder() when (kind) { is Kind.New -> { - closedGroupControlMessage.type = DataMessage.ClosedGroupControlMessage.Type.NEW + closedGroupControlMessage.type = NEW closedGroupControlMessage.publicKey = kind.publicKey closedGroupControlMessage.name = kind.name - val encryptionKeyPair = SignalServiceProtos.KeyPair.newBuilder() - encryptionKeyPair.publicKey = ByteString.copyFrom(kind.encryptionKeyPair!!.publicKey.serialize().removingIdPrefixIfNeeded()) - encryptionKeyPair.privateKey = ByteString.copyFrom(kind.encryptionKeyPair!!.privateKey.serialize()) - closedGroupControlMessage.encryptionKeyPair = encryptionKeyPair.build() + closedGroupControlMessage.encryptionKeyPair = SignalServiceProtos.KeyPair.newBuilder().also { + it.publicKey = ByteString.copyFrom(kind.encryptionKeyPair!!.publicKey.serialize().removingIdPrefixIfNeeded()) + it.privateKey = ByteString.copyFrom(kind.encryptionKeyPair!!.privateKey.serialize()) + }.build() closedGroupControlMessage.addAllMembers(kind.members) closedGroupControlMessage.addAllAdmins(kind.admins) closedGroupControlMessage.expirationTimer = kind.expirationTimer } is Kind.EncryptionKeyPair -> { - closedGroupControlMessage.type = DataMessage.ClosedGroupControlMessage.Type.ENCRYPTION_KEY_PAIR + closedGroupControlMessage.type = ENCRYPTION_KEY_PAIR closedGroupControlMessage.publicKey = kind.publicKey ?: ByteString.EMPTY closedGroupControlMessage.addAllWrappers(kind.wrappers.map { it.toProto() }) } is Kind.NameChange -> { - closedGroupControlMessage.type = DataMessage.ClosedGroupControlMessage.Type.NAME_CHANGE + closedGroupControlMessage.type = NAME_CHANGE closedGroupControlMessage.name = kind.name } is Kind.MembersAdded -> { - closedGroupControlMessage.type = DataMessage.ClosedGroupControlMessage.Type.MEMBERS_ADDED + closedGroupControlMessage.type = MEMBERS_ADDED closedGroupControlMessage.addAllMembers(kind.members) } is Kind.MembersRemoved -> { - closedGroupControlMessage.type = DataMessage.ClosedGroupControlMessage.Type.MEMBERS_REMOVED + closedGroupControlMessage.type = MEMBERS_REMOVED closedGroupControlMessage.addAllMembers(kind.members) } is Kind.MemberLeft -> { - closedGroupControlMessage.type = DataMessage.ClosedGroupControlMessage.Type.MEMBER_LEFT + closedGroupControlMessage.type = MEMBER_LEFT } } - val contentProto = SignalServiceProtos.Content.newBuilder() - val dataMessageProto = DataMessage.newBuilder() - dataMessageProto.closedGroupControlMessage = closedGroupControlMessage.build() - // Group context - setGroupContext(dataMessageProto) - contentProto.dataMessage = dataMessageProto.build() - return contentProto.build() + return SignalServiceProtos.Content.newBuilder().apply { + dataMessage = DataMessage.newBuilder().also { + it.closedGroupControlMessage = closedGroupControlMessage.build() + it.setGroupContext() + }.build() + }.build() } catch (e: Exception) { Log.w(TAG, "Couldn't construct closed group control message proto from: $this.") return null @@ -191,11 +177,9 @@ class ClosedGroupControlMessage() : ControlMessage() { } fun toProto(): DataMessage.ClosedGroupControlMessage.KeyPairWrapper? { - val publicKey = publicKey ?: return null - val encryptedKeyPair = encryptedKeyPair ?: return null val result = DataMessage.ClosedGroupControlMessage.KeyPairWrapper.newBuilder() - result.publicKey = ByteString.copyFrom(Hex.fromStringCondensed(publicKey)) - result.encryptedKeyPair = encryptedKeyPair + result.publicKey = ByteString.copyFrom(Hex.fromStringCondensed(publicKey ?: return null)) + result.encryptedKeyPair = encryptedKeyPair ?: return null return try { result.build() } catch (e: Exception) { @@ -204,4 +188,4 @@ class ClosedGroupControlMessage() : ControlMessage() { } } } -} \ No newline at end of file +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt index eae9a76730..7544ff9c82 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ConfigurationMessage.kt @@ -2,28 +2,28 @@ package org.session.libsession.messaging.messages.control import com.google.protobuf.ByteString import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil -import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.ProfileKeyUtil -import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.removingIdPrefixIfNeeded import org.session.libsignal.utilities.toHexString -import org.session.libsignal.utilities.Hex class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups: List<String>, var contacts: List<Contact>, var displayName: String, var profilePicture: String?, var profileKey: ByteArray) : ControlMessage() { override val isSelfSendValid: Boolean = true - class ClosedGroup(var publicKey: String, var name: String, var encryptionKeyPair: ECKeyPair?, var members: List<String>, var admins: List<String>, var expirationTimer: Int) { + class ClosedGroup(var publicKey: String, var name: String, var encryptionKeyPair: ECKeyPair?, var members: List<String>, var admins: List<String>) { val isValid: Boolean get() = members.isNotEmpty() && admins.isNotEmpty() - internal constructor() : this("", "", null, listOf(), listOf(), 0) + internal constructor() : this("", "", null, listOf(), listOf()) override fun toString(): String { return name @@ -40,8 +40,7 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups: DjbECPrivateKey(encryptionKeyPairAsProto.privateKey.toByteArray())) val members = proto.membersList.map { it.toByteArray().toHexString() } val admins = proto.adminsList.map { it.toByteArray().toHexString() } - val expirationTimer = proto.expirationTimer - return ClosedGroup(publicKey, name, encryptionKeyPair, members, admins, expirationTimer) + return ClosedGroup(publicKey, name, encryptionKeyPair, members, admins) } } @@ -55,7 +54,6 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups: result.encryptionKeyPair = encryptionKeyPairAsProto.build() result.addAllMembers(members.map { ByteString.copyFrom(Hex.fromStringCondensed(it)) }) result.addAllAdmins(admins.map { ByteString.copyFrom(Hex.fromStringCondensed(it)) }) - result.expirationTimer = expirationTimer return result.build() } } @@ -128,8 +126,13 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups: if (!group.members.contains(Address.fromSerialized(storage.getUserPublicKey()!!))) continue val groupPublicKey = GroupUtil.doubleDecodeGroupID(group.encodedId).toHexString() val encryptionKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: continue - val recipient = Recipient.from(context, Address.fromSerialized(group.encodedId), false) - val closedGroup = ClosedGroup(groupPublicKey, group.title, encryptionKeyPair, group.members.map { it.serialize() }, group.admins.map { it.serialize() }, recipient.expireMessages) + val closedGroup = ClosedGroup( + groupPublicKey, + group.title, + encryptionKeyPair, + group.members.map { it.serialize() }, + group.admins.map { it.serialize() } + ) closedGroups.add(closedGroup) } if (group.isOpenGroup) { @@ -152,6 +155,7 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups: val profileKey = configurationProto.profileKey val contacts = configurationProto.contactsList.mapNotNull { Contact.fromProto(it) } return ConfigurationMessage(closedGroups, openGroups, contacts, displayName, profilePicture, profileKey.toByteArray()) + .copyExpiration(proto) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt index a637b26326..7ac009b6a6 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/DataExtractionNotification.kt @@ -1,11 +1,14 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log class DataExtractionNotification() : ControlMessage() { var kind: Kind? = null + override val coerceDisappearAfterSendToRead = true + sealed class Kind { class Screenshot() : Kind() class MediaSaved(val timestamp: Long) : Kind() @@ -31,6 +34,7 @@ class DataExtractionNotification() : ControlMessage() { } } return DataExtractionNotification(kind) + .copyExpiration(proto) } } @@ -62,9 +66,10 @@ class DataExtractionNotification() : ControlMessage() { dataExtractionNotification.timestamp = kind.timestamp } } - val contentProto = SignalServiceProtos.Content.newBuilder() - contentProto.dataExtractionNotification = dataExtractionNotification.build() - return contentProto.build() + return SignalServiceProtos.Content.newBuilder() + .setDataExtractionNotification(dataExtractionNotification.build()) + .applyExpiryMode() + .build() } catch (e: Exception) { Log.w(TAG, "Couldn't construct data extraction notification proto from: $this") return null diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt index e03a92e134..58df8aa7b5 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ExpirationTimerUpdate.kt @@ -1,77 +1,52 @@ package org.session.libsession.messaging.messages.control import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsignal.utilities.Log +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.protos.SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE +import org.session.libsignal.utilities.Log -class ExpirationTimerUpdate() : ControlMessage() { - /** In the case of a sync message, the public key of the person the message was targeted at. - * - * **Note:** `nil` if this isn't a sync message. - */ - var syncTarget: String? = null - var duration: Int? = 0 - +/** In the case of a sync message, the public key of the person the message was targeted at. + * + * **Note:** `nil` if this isn't a sync message. + */ +data class ExpirationTimerUpdate(var syncTarget: String? = null, val isGroup: Boolean = false) : ControlMessage() { override val isSelfSendValid: Boolean = true - override fun isValid(): Boolean { - if (!super.isValid()) return false - return duration != null - } - companion object { const val TAG = "ExpirationTimerUpdate" + private val storage = MessagingModuleConfiguration.shared.storage - fun fromProto(proto: SignalServiceProtos.Content): ExpirationTimerUpdate? { - val dataMessageProto = if (proto.hasDataMessage()) proto.dataMessage else return null - val isExpirationTimerUpdate = dataMessageProto.flags.and(SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE) != 0 - if (!isExpirationTimerUpdate) return null - val syncTarget = dataMessageProto.syncTarget - val duration = dataMessageProto.expireTimer - return ExpirationTimerUpdate(syncTarget, duration) - } - } - - constructor(duration: Int) : this() { - this.syncTarget = null - this.duration = duration - } - - internal constructor(syncTarget: String, duration: Int) : this() { - this.syncTarget = syncTarget - this.duration = duration + fun fromProto(proto: SignalServiceProtos.Content): ExpirationTimerUpdate? = + proto.dataMessage?.takeIf { it.flags and EXPIRATION_TIMER_UPDATE_VALUE != 0 }?.run { + ExpirationTimerUpdate(takeIf { hasSyncTarget() }?.syncTarget, hasGroup()).copyExpiration(proto) + } } override fun toProto(): SignalServiceProtos.Content? { - val duration = duration - if (duration == null) { - Log.w(TAG, "Couldn't construct expiration timer update proto from: $this") - return null + val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder().apply { + flags = EXPIRATION_TIMER_UPDATE_VALUE + expireTimer = expiryMode.expirySeconds.toInt() } - val dataMessageProto = SignalServiceProtos.DataMessage.newBuilder() - dataMessageProto.flags = SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE - dataMessageProto.expireTimer = duration // Sync target - if (syncTarget != null) { - dataMessageProto.syncTarget = syncTarget - } + syncTarget?.let { dataMessageProto.syncTarget = it } // Group context - if (MessagingModuleConfiguration.shared.storage.isClosedGroup(recipient!!)) { + if (storage.isClosedGroup(recipient!!)) { try { - setGroupContext(dataMessageProto) + dataMessageProto.setGroupContext() } catch(e: Exception) { - Log.w(VisibleMessage.TAG, "Couldn't construct visible message proto from: $this") + Log.w(TAG, "Couldn't construct visible message proto from: $this", e) return null } } - val contentProto = SignalServiceProtos.Content.newBuilder() - try { - contentProto.dataMessage = dataMessageProto.build() - return contentProto.build() + return try { + SignalServiceProtos.Content.newBuilder() + .setDataMessage(dataMessageProto) + .applyExpiryMode() + .build() } catch (e: Exception) { - Log.w(TAG, "Couldn't construct expiration timer update proto from: $this") - return null + Log.w(TAG, "Couldn't construct expiration timer update proto from: $this", e) + null } } -} \ No newline at end of file +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt index 614a6eb811..df30b9cb74 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/MessageRequestResponse.kt @@ -1,6 +1,7 @@ package org.session.libsession.messaging.messages.control import com.google.protobuf.ByteString +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsession.messaging.messages.visible.Profile import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log @@ -19,6 +20,7 @@ class MessageRequestResponse(val isApproved: Boolean, var profile: Profile? = nu profile?.profileKey?.let { messageRequestResponseProto.profileKey = ByteString.copyFrom(it) } return try { SignalServiceProtos.Content.newBuilder() + .applyExpiryMode() .setMessageRequestResponse(messageRequestResponseProto.build()) .build() } catch (e: Exception) { @@ -40,7 +42,7 @@ class MessageRequestResponse(val isApproved: Boolean, var profile: Profile? = nu profilePictureURL = profileProto.profilePicture } return MessageRequestResponse(isApproved, profile) + .copyExpiration(proto) } } - -} \ No newline at end of file +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt index e0c7b690bc..e635c3c0cf 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/ReadReceipt.kt @@ -1,5 +1,6 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log @@ -22,10 +23,11 @@ class ReadReceipt() : ControlMessage() { val timestamps = receiptProto.timestampList if (timestamps.isEmpty()) return null return ReadReceipt(timestamps = timestamps) + .copyExpiration(proto) } } - internal constructor(timestamps: List<Long>?) : this() { + constructor(timestamps: List<Long>?) : this() { this.timestamps = timestamps } @@ -35,16 +37,18 @@ class ReadReceipt() : ControlMessage() { Log.w(TAG, "Couldn't construct read receipt proto from: $this") return null } - val receiptProto = SignalServiceProtos.ReceiptMessage.newBuilder() - receiptProto.type = SignalServiceProtos.ReceiptMessage.Type.READ - receiptProto.addAllTimestamp(timestamps.asIterable()) - val contentProto = SignalServiceProtos.Content.newBuilder() - try { - contentProto.receiptMessage = receiptProto.build() - return contentProto.build() + + return try { + SignalServiceProtos.Content.newBuilder() + .setReceiptMessage( + SignalServiceProtos.ReceiptMessage.newBuilder() + .setType(SignalServiceProtos.ReceiptMessage.Type.READ) + .addAllTimestamp(timestamps.asIterable()).build() + ).applyExpiryMode() + .build() } catch (e: Exception) { Log.w(TAG, "Couldn't construct read receipt proto from: $this") - return null + null } } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt index 72b2474965..7ec4eaa6f0 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt @@ -10,12 +10,10 @@ class SharedConfigurationMessage(val kind: SharedConfigMessage.Kind, val data: B override val isSelfSendValid: Boolean = true companion object { - fun fromProto(proto: SignalServiceProtos.Content): SharedConfigurationMessage? { - if (!proto.hasSharedConfigMessage()) return null - val sharedConfig = proto.sharedConfigMessage - if (!sharedConfig.hasKind() || !sharedConfig.hasData()) return null - return SharedConfigurationMessage(sharedConfig.kind, sharedConfig.data.toByteArray(), sharedConfig.seqno) - } + fun fromProto(proto: SignalServiceProtos.Content): SharedConfigurationMessage? = + proto.takeIf { it.hasSharedConfigMessage() }?.sharedConfigMessage + ?.takeIf { it.hasKind() && it.hasData() } + ?.run { SharedConfigurationMessage(kind, data.toByteArray(), seqno) } } override fun isValid(): Boolean { diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt index c755751ba6..343bb63544 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/TypingIndicator.kt @@ -1,12 +1,13 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log class TypingIndicator() : ControlMessage() { var kind: Kind? = null - override val ttl: Long = 20 * 1000 + override val defaultTtl: Long = 20 * 1000 override fun isValid(): Boolean { if (!super.isValid()) return false @@ -20,6 +21,7 @@ class TypingIndicator() : ControlMessage() { val typingIndicatorProto = if (proto.hasTypingMessage()) proto.typingMessage else return null val kind = Kind.fromProto(typingIndicatorProto.action) return TypingIndicator(kind = kind) + .copyExpiration(proto) } } @@ -54,16 +56,14 @@ class TypingIndicator() : ControlMessage() { Log.w(TAG, "Couldn't construct typing indicator proto from: $this") return null } - val typingIndicatorProto = SignalServiceProtos.TypingMessage.newBuilder() - typingIndicatorProto.timestamp = timestamp - typingIndicatorProto.action = kind.toProto() - val contentProto = SignalServiceProtos.Content.newBuilder() - try { - contentProto.typingMessage = typingIndicatorProto.build() - return contentProto.build() + return try { + SignalServiceProtos.Content.newBuilder() + .setTypingMessage(SignalServiceProtos.TypingMessage.newBuilder().setTimestamp(timestamp).setAction(kind.toProto()).build()) + .applyExpiryMode() + .build() } catch (e: Exception) { Log.w(TAG, "Couldn't construct typing indicator proto from: $this") - return null + null } } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt index a22dd93348..a8fd327285 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/UnsendRequest.kt @@ -1,11 +1,10 @@ package org.session.libsession.messaging.messages.control +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log -class UnsendRequest(): ControlMessage() { - var timestamp: Long? = null - var author: String? = null +class UnsendRequest(var timestamp: Long? = null, var author: String? = null): ControlMessage() { override val isSelfSendValid: Boolean = true @@ -19,17 +18,8 @@ class UnsendRequest(): ControlMessage() { companion object { const val TAG = "UnsendRequest" - fun fromProto(proto: SignalServiceProtos.Content): UnsendRequest? { - val unsendRequestProto = if (proto.hasUnsendRequest()) proto.unsendRequest else return null - val timestamp = unsendRequestProto.timestamp - val author = unsendRequestProto.author - return UnsendRequest(timestamp, author) - } - } - - constructor(timestamp: Long, author: String) : this() { - this.timestamp = timestamp - this.author = author + fun fromProto(proto: SignalServiceProtos.Content): UnsendRequest? = + proto.takeIf { it.hasUnsendRequest() }?.unsendRequest?.run { UnsendRequest(timestamp, author) }?.copyExpiration(proto) } override fun toProto(): SignalServiceProtos.Content? { @@ -39,16 +29,14 @@ class UnsendRequest(): ControlMessage() { Log.w(TAG, "Couldn't construct unsend request proto from: $this") return null } - val unsendRequestProto = SignalServiceProtos.UnsendRequest.newBuilder() - unsendRequestProto.timestamp = timestamp - unsendRequestProto.author = author - val contentProto = SignalServiceProtos.Content.newBuilder() - try { - contentProto.unsendRequest = unsendRequestProto.build() - return contentProto.build() + return try { + SignalServiceProtos.Content.newBuilder() + .setUnsendRequest(SignalServiceProtos.UnsendRequest.newBuilder().setTimestamp(timestamp).setAuthor(author).build()) + .applyExpiryMode() + .build() } catch (e: Exception) { Log.w(TAG, "Couldn't construct unsend request proto from: $this") - return null + null } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java index ab24234e81..ce668d0369 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java @@ -26,6 +26,7 @@ public class IncomingMediaMessage { private final long sentTimeMillis; private final int subscriptionId; private final long expiresIn; + private final long expireStartedAt; private final boolean expirationUpdate; private final boolean unidentified; private final boolean messageRequestResponse; @@ -42,6 +43,7 @@ public class IncomingMediaMessage { long sentTimeMillis, int subscriptionId, long expiresIn, + long expireStartedAt, boolean expirationUpdate, boolean unidentified, boolean messageRequestResponse, @@ -60,6 +62,7 @@ public class IncomingMediaMessage { this.body = body.orNull(); this.subscriptionId = subscriptionId; this.expiresIn = expiresIn; + this.expireStartedAt = expireStartedAt; this.expirationUpdate = expirationUpdate; this.dataExtractionNotification = dataExtractionNotification.orNull(); this.quote = quote.orNull(); @@ -78,12 +81,13 @@ public class IncomingMediaMessage { public static IncomingMediaMessage from(VisibleMessage message, Address from, long expiresIn, + long expireStartedAt, Optional<SignalServiceGroup> group, List<SignalServiceAttachment> attachments, Optional<QuoteModel> quote, Optional<List<LinkPreview>> linkPreviews) { - return new IncomingMediaMessage(from, message.getSentTimestamp(), -1, expiresIn, false, + return new IncomingMediaMessage(from, message.getSentTimestamp(), -1, expiresIn, expireStartedAt, false, false, false, message.getHasMention(), Optional.fromNullable(message.getText()), group, Optional.fromNullable(attachments), quote, Optional.absent(), linkPreviews, Optional.absent()); } @@ -124,6 +128,10 @@ public class IncomingMediaMessage { return expiresIn; } + public long getExpireStartedAt() { + return expireStartedAt; + } + public boolean isGroupMessage() { return groupId != null; } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java index ca8e89f1e0..3395a06237 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/IncomingTextMessage.java @@ -41,6 +41,7 @@ public class IncomingTextMessage implements Parcelable { private final boolean push; private final int subscriptionId; private final long expiresInMillis; + private final long expireStartedAt; private final boolean unidentified; private final int callType; private final boolean hasMention; @@ -49,19 +50,19 @@ public class IncomingTextMessage implements Parcelable { public IncomingTextMessage(Address sender, int senderDeviceId, long sentTimestampMillis, String encodedBody, Optional<SignalServiceGroup> group, - long expiresInMillis, boolean unidentified, boolean hasMention) { - this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, unidentified, -1, hasMention); + long expiresInMillis, long expireStartedAt, boolean unidentified, boolean hasMention) { + this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, expireStartedAt, unidentified, -1, hasMention); } public IncomingTextMessage(Address sender, int senderDeviceId, long sentTimestampMillis, String encodedBody, Optional<SignalServiceGroup> group, - long expiresInMillis, boolean unidentified, int callType, boolean hasMention) { - this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, unidentified, callType, hasMention, true); + long expiresInMillis, long expireStartedAt, boolean unidentified, int callType, boolean hasMention) { + this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, expireStartedAt, unidentified, callType, hasMention, true); } public IncomingTextMessage(Address sender, int senderDeviceId, long sentTimestampMillis, String encodedBody, Optional<SignalServiceGroup> group, - long expiresInMillis, boolean unidentified, int callType, boolean hasMention, boolean isPush) { + long expiresInMillis, long expireStartedAt, boolean unidentified, int callType, boolean hasMention, boolean isPush) { this.message = encodedBody; this.sender = sender; this.senderDeviceId = senderDeviceId; @@ -73,6 +74,7 @@ public class IncomingTextMessage implements Parcelable { this.push = isPush; this.subscriptionId = -1; this.expiresInMillis = expiresInMillis; + this.expireStartedAt = expireStartedAt; this.unidentified = unidentified; this.callType = callType; this.hasMention = hasMention; @@ -97,6 +99,7 @@ public class IncomingTextMessage implements Parcelable { this.push = (in.readInt() == 1); this.subscriptionId = in.readInt(); this.expiresInMillis = in.readLong(); + this.expireStartedAt = in.readLong(); this.unidentified = in.readInt() == 1; this.isOpenGroupInvitation = in.readInt() == 1; this.callType = in.readInt(); @@ -116,6 +119,7 @@ public class IncomingTextMessage implements Parcelable { this.push = base.isPush(); this.subscriptionId = base.getSubscriptionId(); this.expiresInMillis = base.getExpiresIn(); + this.expireStartedAt = base.getExpireStartedAt(); this.unidentified = base.isUnidentified(); this.isOpenGroupInvitation = base.isOpenGroupInvitation(); this.callType = base.callType; @@ -125,19 +129,23 @@ public class IncomingTextMessage implements Parcelable { public static IncomingTextMessage from(VisibleMessage message, Address sender, Optional<SignalServiceGroup> group, - long expiresInMillis) + long expiresInMillis, + long expireStartedAt) { - return new IncomingTextMessage(sender, 1, message.getSentTimestamp(), message.getText(), group, expiresInMillis, false, message.getHasMention()); + return new IncomingTextMessage(sender, 1, message.getSentTimestamp(), message.getText(), group, expiresInMillis, expireStartedAt, false, message.getHasMention()); } - public static IncomingTextMessage fromOpenGroupInvitation(OpenGroupInvitation openGroupInvitation, Address sender, Long sentTimestamp) - { + public static IncomingTextMessage fromOpenGroupInvitation(OpenGroupInvitation openGroupInvitation, + Address sender, + Long sentTimestamp, + long expiresInMillis, + long expireStartedAt) { String url = openGroupInvitation.getUrl(); String name = openGroupInvitation.getName(); if (url == null || name == null) { return null; } // FIXME: Doing toJSON() to get the body here is weird String body = UpdateMessageData.Companion.buildOpenGroupInvitation(url, name).toJSON(); - IncomingTextMessage incomingTextMessage = new IncomingTextMessage(sender, 1, sentTimestamp, body, Optional.absent(), 0, false, false); + IncomingTextMessage incomingTextMessage = new IncomingTextMessage(sender, 1, sentTimestamp, body, Optional.absent(), expiresInMillis, expireStartedAt, false, false); incomingTextMessage.isOpenGroupInvitation = true; return incomingTextMessage; } @@ -145,8 +153,10 @@ public class IncomingTextMessage implements Parcelable { public static IncomingTextMessage fromCallInfo(CallMessageType callMessageType, Address sender, Optional<SignalServiceGroup> group, - long sentTimestamp) { - return new IncomingTextMessage(sender, 1, sentTimestamp, null, group, 0, false, callMessageType.ordinal(), false, false); + long sentTimestamp, + long expiresInMillis, + long expireStartedAt) { + return new IncomingTextMessage(sender, 1, sentTimestamp, null, group, expiresInMillis, expireStartedAt, false, callMessageType.ordinal(), false, false); } public int getSubscriptionId() { @@ -157,6 +167,10 @@ public class IncomingTextMessage implements Parcelable { return expiresInMillis; } + public long getExpireStartedAt() { + return expireStartedAt; + } + public long getSentTimestampMillis() { return sentTimestampMillis; } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingExpirationUpdateMessage.java b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingExpirationUpdateMessage.java index fe1a4ff009..b42d6d1bde 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingExpirationUpdateMessage.java +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingExpirationUpdateMessage.java @@ -11,9 +11,9 @@ public class OutgoingExpirationUpdateMessage extends OutgoingSecureMediaMessage private final String groupId; - public OutgoingExpirationUpdateMessage(Recipient recipient, long sentTimeMillis, long expiresIn, String groupId) { + public OutgoingExpirationUpdateMessage(Recipient recipient, long sentTimeMillis, long expiresIn, long expireStartedAt, String groupId) { super(recipient, "", new LinkedList<Attachment>(), sentTimeMillis, - DistributionTypes.CONVERSATION, expiresIn, null, Collections.emptyList(), + DistributionTypes.CONVERSATION, expiresIn, expireStartedAt, null, Collections.emptyList(), Collections.emptyList()); this.groupId = groupId; } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java index 43eac8dea9..86db70f435 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingGroupMediaMessage.java @@ -24,6 +24,7 @@ public class OutgoingGroupMediaMessage extends OutgoingSecureMediaMessage { @Nullable final Attachment avatar, long sentTime, long expireIn, + long expireStartedAt, boolean updateMessage, @Nullable QuoteModel quote, @NonNull List<Contact> contacts, @@ -32,7 +33,7 @@ public class OutgoingGroupMediaMessage extends OutgoingSecureMediaMessage { super(recipient, body, new LinkedList<Attachment>() {{if (avatar != null) add(avatar);}}, sentTime, - DistributionTypes.CONVERSATION, expireIn, quote, contacts, previews); + DistributionTypes.CONVERSATION, expireIn, expireStartedAt, quote, contacts, previews); this.groupID = groupId; this.isUpdateMessage = updateMessage; diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java index 08cb2c4b02..85dc0cc6a2 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingMediaMessage.java @@ -4,13 +4,13 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.session.libsession.messaging.messages.visible.VisibleMessage; +import org.session.libsession.messaging.sending_receiving.attachments.Attachment; +import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; +import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel; +import org.session.libsession.utilities.Contact; import org.session.libsession.utilities.DistributionTypes; import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.NetworkFailure; -import org.session.libsession.messaging.sending_receiving.attachments.Attachment; -import org.session.libsession.utilities.Contact; -import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; -import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel; import org.session.libsession.utilities.recipients.Recipient; import java.util.Collections; @@ -26,6 +26,7 @@ public class OutgoingMediaMessage { private final int distributionType; private final int subscriptionId; private final long expiresIn; + private final long expireStartedAt; private final QuoteModel outgoingQuote; private final List<NetworkFailure> networkFailures = new LinkedList<>(); @@ -35,7 +36,7 @@ public class OutgoingMediaMessage { public OutgoingMediaMessage(Recipient recipient, String message, List<Attachment> attachments, long sentTimeMillis, - int subscriptionId, long expiresIn, + int subscriptionId, long expiresIn, long expireStartedAt, int distributionType, @Nullable QuoteModel outgoingQuote, @NonNull List<Contact> contacts, @@ -50,6 +51,7 @@ public class OutgoingMediaMessage { this.attachments = attachments; this.subscriptionId = subscriptionId; this.expiresIn = expiresIn; + this.expireStartedAt = expireStartedAt; this.outgoingQuote = outgoingQuote; this.contacts.addAll(contacts); @@ -66,6 +68,7 @@ public class OutgoingMediaMessage { this.sentTimeMillis = that.sentTimeMillis; this.subscriptionId = that.subscriptionId; this.expiresIn = that.expiresIn; + this.expireStartedAt = that.expireStartedAt; this.outgoingQuote = that.outgoingQuote; this.identityKeyMismatches.addAll(that.identityKeyMismatches); @@ -78,14 +81,16 @@ public class OutgoingMediaMessage { Recipient recipient, List<Attachment> attachments, @Nullable QuoteModel outgoingQuote, - @Nullable LinkPreview linkPreview) + @Nullable LinkPreview linkPreview, + long expiresInMillis, + long expireStartedAt) { List<LinkPreview> previews = Collections.emptyList(); if (linkPreview != null) { previews = Collections.singletonList(linkPreview); } return new OutgoingMediaMessage(recipient, message.getText(), attachments, message.getSentTimestamp(), -1, - recipient.getExpireMessages() * 1000, DistributionTypes.DEFAULT, outgoingQuote, + expiresInMillis, expireStartedAt, DistributionTypes.DEFAULT, outgoingQuote, Collections.emptyList(), previews, Collections.emptyList(), Collections.emptyList()); } @@ -123,6 +128,10 @@ public class OutgoingMediaMessage { return expiresIn; } + public long getExpireStartedAt() { + return expireStartedAt; + } + public @Nullable QuoteModel getOutgoingQuote() { return outgoingQuote; } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java index 907d1a9dda..e93c3c5986 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingSecureMediaMessage.java @@ -19,11 +19,12 @@ public class OutgoingSecureMediaMessage extends OutgoingMediaMessage { long sentTimeMillis, int distributionType, long expiresIn, + long expireStartedAt, @Nullable QuoteModel quote, @NonNull List<Contact> contacts, @NonNull List<LinkPreview> previews) { - super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, distributionType, quote, contacts, previews, Collections.emptyList(), Collections.emptyList()); + super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, expireStartedAt, distributionType, quote, contacts, previews, Collections.emptyList(), Collections.emptyList()); } public OutgoingSecureMediaMessage(OutgoingMediaMessage base) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java index abc39b662e..b62a75a1e3 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/signal/OutgoingTextMessage.java @@ -10,28 +10,30 @@ public class OutgoingTextMessage { private final String message; private final int subscriptionId; private final long expiresIn; + private final long expireStartedAt; private final long sentTimestampMillis; private boolean isOpenGroupInvitation = false; - public OutgoingTextMessage(Recipient recipient, String message, long expiresIn, int subscriptionId, long sentTimestampMillis) { + public OutgoingTextMessage(Recipient recipient, String message, long expiresIn, long expireStartedAt, int subscriptionId, long sentTimestampMillis) { this.recipient = recipient; this.message = message; this.expiresIn = expiresIn; + this.expireStartedAt= expireStartedAt; this.subscriptionId = subscriptionId; this.sentTimestampMillis = sentTimestampMillis; } - public static OutgoingTextMessage from(VisibleMessage message, Recipient recipient) { - return new OutgoingTextMessage(recipient, message.getText(), recipient.getExpireMessages() * 1000, -1, message.getSentTimestamp()); + public static OutgoingTextMessage from(VisibleMessage message, Recipient recipient, long expiresInMillis, long expireStartedAt) { + return new OutgoingTextMessage(recipient, message.getText(), expiresInMillis, expireStartedAt, -1, message.getSentTimestamp()); } - public static OutgoingTextMessage fromOpenGroupInvitation(OpenGroupInvitation openGroupInvitation, Recipient recipient, Long sentTimestamp) { + public static OutgoingTextMessage fromOpenGroupInvitation(OpenGroupInvitation openGroupInvitation, Recipient recipient, Long sentTimestamp, long expiresInMillis, long expireStartedAt) { String url = openGroupInvitation.getUrl(); String name = openGroupInvitation.getName(); if (url == null || name == null) { return null; } // FIXME: Doing toJSON() to get the body here is weird String body = UpdateMessageData.Companion.buildOpenGroupInvitation(url, name).toJSON(); - OutgoingTextMessage outgoingTextMessage = new OutgoingTextMessage(recipient, body, 0, -1, sentTimestamp); + OutgoingTextMessage outgoingTextMessage = new OutgoingTextMessage(recipient, body, expiresInMillis, expireStartedAt, -1, sentTimestamp); outgoingTextMessage.isOpenGroupInvitation = true; return outgoingTextMessage; } @@ -40,6 +42,10 @@ public class OutgoingTextMessage { return expiresIn; } + public long getExpireStartedAt() { + return expireStartedAt; + } + public int getSubscriptionId() { return subscriptionId; } diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt index ce6b61524c..9c95bbcfbd 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/visible/Profile.kt @@ -4,10 +4,11 @@ import com.google.protobuf.ByteString import org.session.libsignal.utilities.Log import org.session.libsignal.protos.SignalServiceProtos -class Profile() { - var displayName: String? = null - var profileKey: ByteArray? = null +class Profile( + var displayName: String? = null, + var profileKey: ByteArray? = null, var profilePictureURL: String? = null +) { companion object { const val TAG = "Profile" @@ -25,12 +26,6 @@ class Profile() { } } - constructor(displayName: String, profileKey: ByteArray? = null, profilePictureURL: String? = null) : this() { - this.displayName = displayName - this.profileKey = profileKey - this.profilePictureURL = profilePictureURL - } - fun toProto(): SignalServiceProtos.DataMessage? { val displayName = displayName if (displayName == null) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt b/libsession/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt index 705fc7ce45..7ced3836a3 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/visible/VisibleMessage.kt @@ -3,10 +3,8 @@ package org.session.libsession.messaging.messages.visible import com.goterl.lazysodium.BuildConfig import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.copyExpiration import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.GroupUtil -import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Log import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment @@ -16,7 +14,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment * * **Note:** `nil` if this isn't a sync message. */ -class VisibleMessage( +data class VisibleMessage( var syncTarget: String? = null, var text: String? = null, val attachmentIDs: MutableList<Long> = mutableListOf(), @@ -25,7 +23,8 @@ class VisibleMessage( var profile: Profile? = null, var openGroupInvitation: OpenGroupInvitation? = null, var reaction: Reaction? = null, - var hasMention: Boolean = false + var hasMention: Boolean = false, + var blocksMessageRequests: Boolean = false ) : Message() { override val isSelfSendValid: Boolean = true @@ -45,49 +44,26 @@ class VisibleMessage( companion object { const val TAG = "VisibleMessage" - fun fromProto(proto: SignalServiceProtos.Content): VisibleMessage? { - val dataMessage = proto.dataMessage ?: return null - val result = VisibleMessage() - if (dataMessage.hasSyncTarget()) { result.syncTarget = dataMessage.syncTarget } - result.text = dataMessage.body - // Attachments are handled in MessageReceiver - val quoteProto = if (dataMessage.hasQuote()) dataMessage.quote else null - if (quoteProto != null) { - val quote = Quote.fromProto(quoteProto) - result.quote = quote - } - val linkPreviewProto = dataMessage.previewList.firstOrNull() - if (linkPreviewProto != null) { - val linkPreview = LinkPreview.fromProto(linkPreviewProto) - result.linkPreview = linkPreview - } - val openGroupInvitationProto = if (dataMessage.hasOpenGroupInvitation()) dataMessage.openGroupInvitation else null - if (openGroupInvitationProto != null) { - val openGroupInvitation = OpenGroupInvitation.fromProto(openGroupInvitationProto) - result.openGroupInvitation = openGroupInvitation - } - // TODO Contact - val profile = Profile.fromProto(dataMessage) - if (profile != null) { result.profile = profile } - val reactionProto = if (dataMessage.hasReaction()) dataMessage.reaction else null - if (reactionProto != null) { - val reaction = Reaction.fromProto(reactionProto) - result.reaction = reaction - } - return result + fun fromProto(proto: SignalServiceProtos.Content): VisibleMessage? = + proto.dataMessage?.let { VisibleMessage().apply { + if (it.hasSyncTarget()) syncTarget = it.syncTarget + text = it.body + // Attachments are handled in MessageReceiver + if (it.hasQuote()) quote = Quote.fromProto(it.quote) + linkPreview = it.previewList.firstOrNull()?.let(LinkPreview::fromProto) + if (it.hasOpenGroupInvitation()) openGroupInvitation = it.openGroupInvitation?.let(OpenGroupInvitation::fromProto) + // TODO Contact + profile = Profile.fromProto(it) + if (it.hasReaction()) reaction = it.reaction?.let(Reaction::fromProto) + blocksMessageRequests = it.hasBlocksCommunityMessageRequests() && it.blocksCommunityMessageRequests + }.copyExpiration(proto) } } override fun toProto(): SignalServiceProtos.Content? { val proto = SignalServiceProtos.Content.newBuilder() - val dataMessage: SignalServiceProtos.DataMessage.Builder // Profile - val profileProto = profile?.toProto() - dataMessage = if (profileProto != null) { - profileProto.toBuilder() - } else { - SignalServiceProtos.DataMessage.newBuilder() - } + val dataMessage = profile?.toProto()?.toBuilder() ?: SignalServiceProtos.DataMessage.newBuilder() // Text if (text != null) { dataMessage.body = text } // Quote @@ -122,25 +98,19 @@ class VisibleMessage( dataMessage.addAllAttachments(pointers) // TODO: Contact // Expiration timer - // TODO: We * want * expiration timer updates to be explicit. But currently Android will disable the expiration timer for a conversation - // if it receives a message without the current expiration timer value attached to it... - val storage = MessagingModuleConfiguration.shared.storage - val context = MessagingModuleConfiguration.shared.context - val expiration = if (storage.isClosedGroup(recipient!!)) { - Recipient.from(context, Address.fromSerialized(GroupUtil.doubleEncodeGroupID(recipient!!)), false).expireMessages - } else { - Recipient.from(context, Address.fromSerialized(recipient!!), false).expireMessages - } - dataMessage.expireTimer = expiration + proto.applyExpiryMode() // Group context + val storage = MessagingModuleConfiguration.shared.storage if (storage.isClosedGroup(recipient!!)) { try { - setGroupContext(dataMessage) + dataMessage.setGroupContext() } catch (e: Exception) { Log.w(TAG, "Couldn't construct visible message proto from: $this") return null } } + // Community blocked message requests flag + dataMessage.blocksCommunityMessageRequests = blocksMessageRequests // Sync target if (syncTarget != null) { dataMessage.syncTarget = syncTarget @@ -167,4 +137,4 @@ class VisibleMessage( fun isMediaMessage(): Boolean { return attachmentIDs.isNotEmpty() || quote != null || linkPreview != null || reaction != null } -} \ No newline at end of file +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt index dc6d1475f8..6e73c16f5e 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroupApi.kt @@ -21,6 +21,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller.Companion.maxInactivityPeriod import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionResponse import org.session.libsession.snode.SnodeAPI @@ -48,7 +49,6 @@ object OpenGroupApi { val defaultRooms = MutableSharedFlow<List<DefaultGroup>>(replay = 1) private val hasPerformedInitialPoll = mutableMapOf<String, Boolean>() private var hasUpdatedLastOpenDate = false - private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } private val timeSinceLastOpen by lazy { val context = MessagingModuleConfiguration.shared.context val lastOpenDate = TextSecurePreferences.getLastOpenTimeDate(context) @@ -273,7 +273,6 @@ object OpenGroupApi { val queryParameters: Map<String, String> = mapOf(), val parameters: Any? = null, val headers: Map<String, String> = mapOf(), - val isAuthRequired: Boolean = true, val body: ByteArray? = null, /** * Always `true` under normal circumstances. You might want to disable @@ -319,73 +318,72 @@ object OpenGroupApi { ?: return Promise.ofFail(Error.NoEd25519KeyPair) val urlRequest = urlBuilder.toString() val headers = request.headers.toMutableMap() - if (request.isAuthRequired) { - val nonce = sodium.nonce(16) - val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset) - var pubKey = "" - var signature = ByteArray(Sign.BYTES) - var bodyHash = ByteArray(0) - if (request.parameters != null) { - val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray() - val parameterHash = ByteArray(GenericHash.BYTES_MAX) - if (sodium.cryptoGenericHash( - parameterHash, - parameterHash.size, - parameterBytes, - parameterBytes.size.toLong() - ) - ) { - bodyHash = parameterHash - } - } else if (request.body != null) { - val byteHash = ByteArray(GenericHash.BYTES_MAX) - if (sodium.cryptoGenericHash( - byteHash, - byteHash.size, - request.body, - request.body.size.toLong() - ) - ) { - bodyHash = byteHash - } - } - val messageBytes = Hex.fromStringCondensed(publicKey) - .plus(nonce) - .plus("$timestamp".toByteArray(Charsets.US_ASCII)) - .plus(request.verb.rawValue.toByteArray()) - .plus("/${request.endpoint.value}".toByteArray()) - .plus(bodyHash) - if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) { - SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair -> - pubKey = SessionId( - IdPrefix.BLINDED, - keyPair.publicKey.asBytes - ).hexString - signature = SodiumUtilities.sogsSignature( - messageBytes, - ed25519KeyPair.secretKey.asBytes, - keyPair.secretKey.asBytes, - keyPair.publicKey.asBytes - ) ?: return Promise.ofFail(Error.SigningFailed) - } ?: return Promise.ofFail(Error.SigningFailed) - } else { - pubKey = SessionId( - IdPrefix.UN_BLINDED, - ed25519KeyPair.publicKey.asBytes - ).hexString - sodium.cryptoSignDetached( - signature, - messageBytes, - messageBytes.size.toLong(), - ed25519KeyPair.secretKey.asBytes + val nonce = sodium.nonce(16) + val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset) + var pubKey = "" + var signature = ByteArray(Sign.BYTES) + var bodyHash = ByteArray(0) + if (request.parameters != null) { + val parameterBytes = JsonUtil.toJson(request.parameters).toByteArray() + val parameterHash = ByteArray(GenericHash.BYTES_MAX) + if (sodium.cryptoGenericHash( + parameterHash, + parameterHash.size, + parameterBytes, + parameterBytes.size.toLong() ) + ) { + bodyHash = parameterHash + } + } else if (request.body != null) { + val byteHash = ByteArray(GenericHash.BYTES_MAX) + if (sodium.cryptoGenericHash( + byteHash, + byteHash.size, + request.body, + request.body.size.toLong() + ) + ) { + bodyHash = byteHash } - headers["X-SOGS-Nonce"] = encodeBytes(nonce) - headers["X-SOGS-Timestamp"] = "$timestamp" - headers["X-SOGS-Pubkey"] = pubKey - headers["X-SOGS-Signature"] = encodeBytes(signature) } + val messageBytes = Hex.fromStringCondensed(publicKey) + .plus(nonce) + .plus("$timestamp".toByteArray(Charsets.US_ASCII)) + .plus(request.verb.rawValue.toByteArray()) + .plus("/${request.endpoint.value}".toByteArray()) + .plus(bodyHash) + if (serverCapabilities.isEmpty() || serverCapabilities.contains(Capability.BLIND.name.lowercase())) { + SodiumUtilities.blindedKeyPair(publicKey, ed25519KeyPair)?.let { keyPair -> + pubKey = SessionId( + IdPrefix.BLINDED, + keyPair.publicKey.asBytes + ).hexString + + signature = SodiumUtilities.sogsSignature( + messageBytes, + ed25519KeyPair.secretKey.asBytes, + keyPair.secretKey.asBytes, + keyPair.publicKey.asBytes + ) ?: return Promise.ofFail(Error.SigningFailed) + } ?: return Promise.ofFail(Error.SigningFailed) + } else { + pubKey = SessionId( + IdPrefix.UN_BLINDED, + ed25519KeyPair.publicKey.asBytes + ).hexString + sodium.cryptoSignDetached( + signature, + messageBytes, + messageBytes.size.toLong(), + ed25519KeyPair.secretKey.asBytes + ) + } + headers["X-SOGS-Nonce"] = encodeBytes(nonce) + headers["X-SOGS-Timestamp"] = "$timestamp" + headers["X-SOGS-Pubkey"] = pubKey + headers["X-SOGS-Signature"] = encodeBytes(signature) val requestBuilder = okhttp3.Request.Builder() .url(urlRequest) @@ -602,8 +600,7 @@ object OpenGroupApi { // region Message Deletion @JvmStatic fun deleteMessage(serverID: Long, room: String, server: String): Promise<Unit, Exception> { - val request = - Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.RoomMessageIndividual(room, serverID)) + val request = Request(verb = DELETE, room = room, server = server, endpoint = Endpoint.RoomMessageIndividual(room, serverID)) return send(request).map { Log.d("Loki", "Message deletion successful.") } @@ -659,7 +656,9 @@ object OpenGroupApi { } fun banAndDeleteAll(publicKey: String, room: String, server: String): Promise<Unit, Exception> { + val requests = mutableListOf<BatchRequestInfo<*>>( + // Ban request BatchRequestInfo( request = BatchRequest( method = POST, @@ -669,6 +668,7 @@ object OpenGroupApi { endpoint = Endpoint.UserBan(publicKey), responseType = object: TypeReference<Any>(){} ), + // Delete request BatchRequestInfo( request = BatchRequest(DELETE, "/room/$room/all/$publicKey"), endpoint = Endpoint.RoomDeleteMessages(room, publicKey), @@ -753,7 +753,8 @@ object OpenGroupApi { ) } val serverCapabilities = storage.getServerCapabilities(server) - if (serverCapabilities.contains(Capability.BLIND.name.lowercase())) { + val isAcceptingCommunityRequests = storage.isCheckingCommunityRequests() + if (serverCapabilities.contains(Capability.BLIND.name.lowercase()) && isAcceptingCommunityRequests) { requests.add( if (lastInboxMessageId == null) { BatchRequestInfo( @@ -924,7 +925,7 @@ object OpenGroupApi { } fun getCapabilities(server: String): Promise<Capabilities, Exception> { - val request = Request(verb = GET, room = null, server = server, endpoint = Endpoint.Capabilities, isAuthRequired = false) + val request = Request(verb = GET, room = null, server = server, endpoint = Endpoint.Capabilities) return getResponseBody(request).map { response -> JsonUtil.fromJson(response, Capabilities::class.java) } @@ -972,5 +973,17 @@ object OpenGroupApi { } } + fun deleteAllInboxMessages(server: String): Promise<Map<*, *>, java.lang.Exception> { + val request = Request( + verb = DELETE, + room = null, + server = server, + endpoint = Endpoint.Inbox + ) + return getResponseBody(request).map { response -> + JsonUtil.fromJson(response, Map::class.java) + } + } + // endregion } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt index 53bf12f26e..8043da4b74 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt @@ -8,6 +8,7 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.MessageReceiver.Error import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix @@ -17,8 +18,6 @@ import org.session.libsignal.utilities.removingIdPrefixIfNeeded object MessageDecrypter { - private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } - /** * Decrypts `ciphertext` using the Session protocol and `x25519KeyPair`. * diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt index 24a620f8d4..361feff9e8 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt @@ -7,6 +7,7 @@ import com.goterl.lazysodium.interfaces.Sign import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.MessageSender.Error import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log @@ -14,8 +15,6 @@ import org.session.libsignal.utilities.removingIdPrefixIfNeeded object MessageEncrypter { - private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } - /** * Encrypts `plaintext` using the Session protocol for `hexEncodedX25519PublicKey`. * diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt index 34022b7396..8d9d69fb82 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt @@ -38,11 +38,13 @@ object MessageReceiver { object NoGroupThread: Error("No thread exists for this group.") object NoGroupKeyPair: Error("Missing group key pair.") object NoUserED25519KeyPair : Error("Couldn't find user ED25519 key pair.") + object ExpiredMessage: Error("Message has already expired, prevent adding") internal val isRetryable: Boolean = when (this) { is DuplicateMessage, is InvalidMessage, is UnknownMessage, is UnknownEnvelopeType, is InvalidSignature, is NoData, - is SenderBlocked, is SelfSend, is NoGroupThread -> false + is SenderBlocked, is SelfSend, + is ExpiredMessage, is NoGroupThread -> false else -> true } } @@ -143,15 +145,14 @@ object MessageReceiver { MessageRequestResponse.fromProto(proto) ?: CallMessage.fromProto(proto) ?: SharedConfigurationMessage.fromProto(proto) ?: - VisibleMessage.fromProto(proto) ?: run { - throw Error.UnknownMessage - } + VisibleMessage.fromProto(proto) ?: throw Error.UnknownMessage + val isUserBlindedSender = sender == openGroupPublicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!) }?.let { SessionId(IdPrefix.BLINDED, it.publicKey.asBytes).hexString } - // Ignore self send if needed - if (!message.isSelfSendValid && (sender == userPublicKey || isUserBlindedSender)) { - throw Error.SelfSend - } - if (sender == userPublicKey || isUserBlindedSender) { + val isUserSender = sender == userPublicKey + + if (isUserSender || isUserBlindedSender) { + // Ignore self send if needed + if (!message.isSelfSendValid) throw Error.SelfSend message.isSenderSelf = true } // Guard against control messages in open groups @@ -191,4 +192,5 @@ object MessageReceiver { // Return return Pair(message, proto) } + } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index 804b2f15be..0968db27e2 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -1,5 +1,6 @@ package org.session.libsession.messaging.sending_receiving +import network.loki.messenger.libsession_util.util.ExpiryMode import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred import org.session.libsession.messaging.MessagingModuleConfiguration @@ -8,6 +9,7 @@ import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.jobs.NotifyPNServerJob import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.applyExpiryMode import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage import org.session.libsession.messaging.messages.control.ConfigurationMessage @@ -26,14 +28,22 @@ import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.snode.RawResponsePromise import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.SnodeModule import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Device import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.SSKEnvironment import org.session.libsignal.crypto.PushTransportDetails import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.* +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace +import org.session.libsignal.utilities.defaultRequiresAuth +import org.session.libsignal.utilities.hasNamespaces +import org.session.libsignal.utilities.hexEncodedPublicKey import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import org.session.libsession.messaging.sending_receiving.attachments.Attachment as SignalAttachment @@ -63,6 +73,7 @@ object MessageSender { // Convenience fun send(message: Message, destination: Destination, isSyncMessage: Boolean): Promise<Unit, Exception> { + if (message is VisibleMessage) MessagingModuleConfiguration.shared.lastSentTimestampCache.submitTimestamp(message.threadID!!, message.sentTimestamp!!) return if (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) { sendToOpenGroupDestination(destination, message) } else { @@ -76,14 +87,14 @@ object MessageSender { val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey() // Set the timestamp, sender and recipient - val messageSendTime = SnodeAPI.nowWithOffset + val messageSendTime = nowWithOffset if (message.sentTimestamp == null) { message.sentTimestamp = messageSendTime // Visible messages will already have their sent timestamp set } message.sender = userPublicKey - + // SHARED CONFIG when (destination) { is Destination.Contact -> message.recipient = destination.publicKey is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey @@ -154,7 +165,7 @@ object MessageSender { return SnodeMessage( message.recipient!!, base64EncodedData, - message.ttl, + ttl = getSpecifiedTtl(message, isSyncMessage) ?: message.ttl, messageSendTime ) } @@ -184,8 +195,13 @@ object MessageSender { val namespaces: List<Int> = when { destination is Destination.ClosedGroup && forkInfo.defaultRequiresAuth() -> listOf(Namespace.UNAUTHENTICATED_CLOSED_GROUP) + destination is Destination.ClosedGroup - && forkInfo.hasNamespaces() -> listOf(Namespace.UNAUTHENTICATED_CLOSED_GROUP, Namespace.DEFAULT) + && forkInfo.hasNamespaces() -> listOf( + Namespace.UNAUTHENTICATED_CLOSED_GROUP, + Namespace.DEFAULT + ) + else -> listOf(Namespace.DEFAULT) } namespaces.map { namespace -> SnodeAPI.sendMessage(snodeMessage, requiresAuth = false, namespace = namespace) }.let { promises -> @@ -237,12 +253,32 @@ object MessageSender { return promise } + private fun getSpecifiedTtl( + message: Message, + isSyncMessage: Boolean + ): Long? = message.takeUnless { it is ClosedGroupControlMessage }?.run { + threadID ?: (if (isSyncMessage && this is VisibleMessage) syncTarget else recipient) + ?.let(Address.Companion::fromSerialized) + ?.let(MessagingModuleConfiguration.shared.storage::getThreadId) + }?.let(MessagingModuleConfiguration.shared.storage::getExpirationConfiguration) + ?.takeIf { it.isEnabled } + ?.expiryMode + ?.takeIf { it is ExpiryMode.AfterSend || isSyncMessage } + ?.expiryMillis + // Open Groups private fun sendToOpenGroupDestination(destination: Destination, message: Message): Promise<Unit, Exception> { val deferred = deferred<Unit, Exception>() val storage = MessagingModuleConfiguration.shared.storage + val configFactory = MessagingModuleConfiguration.shared.configFactory if (message.sentTimestamp == null) { - message.sentTimestamp = SnodeAPI.nowWithOffset + message.sentTimestamp = nowWithOffset + } + // Attach the blocks message requests info + configFactory.user?.let { user -> + if (message is VisibleMessage) { + message.blocksMessageRequests = !user.getCommunityMessageRequests() + } } val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!! var serverCapabilities = listOf<String>() @@ -336,27 +372,33 @@ object MessageSender { } // Result Handling - fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) { + private fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) { + if (message is VisibleMessage) MessagingModuleConfiguration.shared.lastSentTimestampCache.submitTimestamp(message.threadID!!, openGroupSentTimestamp) val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey()!! + val timestamp = message.sentTimestamp!! // Ignore future self-sends - storage.addReceivedMessageTimestamp(message.sentTimestamp!!) - storage.getMessageIdInDatabase(message.sentTimestamp!!, userPublicKey)?.let { messageID -> + storage.addReceivedMessageTimestamp(timestamp) + storage.getMessageIdInDatabase(timestamp, userPublicKey)?.let { (messageID, mms) -> if (openGroupSentTimestamp != -1L && message is VisibleMessage) { storage.addReceivedMessageTimestamp(openGroupSentTimestamp) storage.updateSentTimestamp(messageID, message.isMediaMessage(), openGroupSentTimestamp, message.threadID!!) message.sentTimestamp = openGroupSentTimestamp } + // When the sync message is successfully sent, the hash value of this TSOutgoingMessage // will be replaced by the hash value of the sync message. Since the hash value of the // real message has no use when we delete a message. It is OK to let it be. message.serverHash?.let { - storage.setMessageServerHash(messageID, it) + storage.setMessageServerHash(messageID, mms, it) } + // in case any errors from previous sends storage.clearErrorMessage(messageID) + // Track the open group server message ID - if (message.openGroupServerMessageID != null && (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup)) { + val messageIsAddressedToCommunity = message.openGroupServerMessageID != null && (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup) + if (messageIsAddressedToCommunity) { val server: String val room: String when (destination) { @@ -378,13 +420,28 @@ object MessageSender { storage.setOpenGroupServerMessageID(messageID, message.openGroupServerMessageID!!, threadID, !(message as VisibleMessage).isMediaMessage()) } } - // Mark the message as sent - storage.markAsSent(message.sentTimestamp!!, userPublicKey) - storage.markUnidentified(message.sentTimestamp!!, userPublicKey) - // Start the disappearing messages timer if needed - if (message is VisibleMessage && !isSyncMessage) { - SSKEnvironment.shared.messageExpirationManager.startAnyExpiration(message.sentTimestamp!!, userPublicKey) + + // Mark the message as sent. + // Note: When sending a message to a community the server modifies the message timestamp + // so when we go to look up the message in the local database by timestamp it fails and + // we're left with the message delivery status as "Sending" forever! As such, we use a + // pair of modified "markAsSentToCommunity" and "markUnidentifiedInCommunity" methods + // to retrieve the local message by thread & message ID rather than timestamp when + // handling community messages only so we can tick the delivery status over to 'Sent'. + // Fixed in: https://optf.atlassian.net/browse/SES-1567 + if (messageIsAddressedToCommunity) + { + storage.markAsSentToCommunity(message.threadID!!, message.id!!) + storage.markUnidentifiedInCommunity(message.threadID!!, message.id!!) } + else + { + storage.markAsSent(timestamp, userPublicKey) + storage.markUnidentified(timestamp, userPublicKey) + } + + // Start the disappearing messages timer if needed + SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(message, startDisappearAfterRead = true) } ?: run { storage.updateReactionIfNeeded(message, message.sender?:userPublicKey, openGroupSentTimestamp) } @@ -396,7 +453,7 @@ object MessageSender { if (message is VisibleMessage) message.syncTarget = destination.publicKey if (message is ExpirationTimerUpdate) message.syncTarget = destination.publicKey - storage.markAsSyncing(message.sentTimestamp!!, userPublicKey) + storage.markAsSyncing(timestamp, userPublicKey) sendToSnodeDestination(Destination.Contact(userPublicKey), message, true) } } @@ -423,7 +480,7 @@ object MessageSender { message.linkPreview?.let { linkPreview -> if (linkPreview.attachmentID == null) { messageDataProvider.getLinkPreviewAttachmentIDFor(message.id!!)?.let { attachmentID -> - message.linkPreview!!.attachmentID = attachmentID + linkPreview.attachmentID = attachmentID message.attachmentIDs.remove(attachmentID) } } @@ -434,6 +491,7 @@ object MessageSender { @JvmStatic fun send(message: Message, address: Address) { val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) + threadID?.let(message::applyExpiryMode) message.threadID = threadID val destination = Destination.from(address) val job = MessageSendJob(message, destination) @@ -454,8 +512,8 @@ object MessageSender { } // Closed groups - fun createClosedGroup(name: String, members: Collection<String>): Promise<String, Exception> { - return create(name, members) + fun createClosedGroup(device: Device, name: String, members: Collection<String>): Promise<String, Exception> { + return create(device, name, members) } fun explicitNameChange(groupPublicKey: String, newName: String) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt index a98b6b1b6b..e30d58b939 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt @@ -8,14 +8,14 @@ import nl.komponents.kovenant.deferred import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage import org.session.libsession.messaging.sending_receiving.MessageSender.Error -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.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.Device import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.crypto.ecc.Curve import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceGroup @@ -26,14 +26,18 @@ import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.removingIdPrefixIfNeeded -import java.util.* +import java.util.LinkedList import java.util.concurrent.ConcurrentHashMap const val groupSizeLimit = 100 val pendingKeyPairs = ConcurrentHashMap<String, Optional<ECKeyPair>>() -fun MessageSender.create(name: String, members: Collection<String>): Promise<String, Exception> { +fun MessageSender.create( + device: Device, + name: String, + members: Collection<String> +): Promise<String, Exception> { val deferred = deferred<String, Exception>() ThreadUtils.queue { // Prepare @@ -49,7 +53,7 @@ fun MessageSender.create(name: String, members: Collection<String>): Promise<Str val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) val admins = setOf( userPublicKey ) val adminsAsData = admins.map { ByteString.copyFrom(Hex.fromStringCondensed(it)) } - storage.createGroup(groupID, name, LinkedList(members.map { Address.fromSerialized(it) }), + storage.createGroup(groupID, name, LinkedList(members.map { fromSerialized(it) }), null, null, LinkedList(admins.map { Address.fromSerialized(it) }), SnodeAPI.nowWithOffset) storage.setProfileSharing(Address.fromSerialized(groupID), true) @@ -70,7 +74,7 @@ fun MessageSender.create(name: String, members: Collection<String>): Promise<Str val ourPubKey = storage.getUserPublicKey() for (member in members) { - val closedGroupControlMessage = ClosedGroupControlMessage(closedGroupUpdateKind) + val closedGroupControlMessage = ClosedGroupControlMessage(closedGroupUpdateKind, groupID) closedGroupControlMessage.sentTimestamp = sentTime try { sendNonDurably(closedGroupControlMessage, Address.fromSerialized(member), member == ourPubKey).get() @@ -87,9 +91,9 @@ fun MessageSender.create(name: String, members: Collection<String>): Promise<Str } // Add the group to the config now that it was successfully created - storage.createInitialConfigGroup(groupPublicKey, name, GroupUtil.createConfigMemberMap(members, admins), sentTime, encryptionKeyPair) + storage.createInitialConfigGroup(groupPublicKey, name, GroupUtil.createConfigMemberMap(members, admins), sentTime, encryptionKeyPair, 0) // Notify the PN server - PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, groupPublicKey, userPublicKey) + PushRegistryV1.register(device = device, publicKey = userPublicKey) // Start polling ClosedGroupPollerV2.shared.startPolling(groupPublicKey) // Fulfill the promise @@ -112,7 +116,7 @@ fun MessageSender.setName(groupPublicKey: String, newName: String) { // Send the update to the group val kind = ClosedGroupControlMessage.Kind.NameChange(newName) val sentTime = SnodeAPI.nowWithOffset - val closedGroupControlMessage = ClosedGroupControlMessage(kind) + val closedGroupControlMessage = ClosedGroupControlMessage(kind, groupID) closedGroupControlMessage.sentTimestamp = sentTime send(closedGroupControlMessage, Address.fromSerialized(groupID)) // Update the group @@ -131,8 +135,8 @@ fun MessageSender.addMembers(groupPublicKey: String, membersToAdd: List<String>) Log.d("Loki", "Can't add members to nonexistent closed group.") throw Error.NoThread } - val recipient = Recipient.from(context, fromSerialized(groupID), false) - val expireTimer = recipient.expireMessages + val threadId = storage.getOrCreateThreadIdFor(fromSerialized(groupID)) + val expireTimer = storage.getExpirationConfiguration(threadId)?.expiryMode?.expirySeconds ?: 0 if (membersToAdd.isEmpty()) { Log.d("Loki", "Invalid closed group update.") throw Error.InvalidClosedGroupUpdate @@ -152,13 +156,20 @@ fun MessageSender.addMembers(groupPublicKey: String, membersToAdd: List<String>) // Send the update to the group val memberUpdateKind = ClosedGroupControlMessage.Kind.MembersAdded(newMembersAsData) val sentTime = SnodeAPI.nowWithOffset - val closedGroupControlMessage = ClosedGroupControlMessage(memberUpdateKind) + val closedGroupControlMessage = ClosedGroupControlMessage(memberUpdateKind, groupID) closedGroupControlMessage.sentTimestamp = sentTime send(closedGroupControlMessage, Address.fromSerialized(groupID)) // Send closed group update messages to any new members individually for (member in membersToAdd) { - val closedGroupNewKind = ClosedGroupControlMessage.Kind.New(ByteString.copyFrom(Hex.fromStringCondensed(groupPublicKey)), name, encryptionKeyPair, membersAsData, adminsAsData, expireTimer) - val closedGroupControlMessage = ClosedGroupControlMessage(closedGroupNewKind) + val closedGroupNewKind = ClosedGroupControlMessage.Kind.New( + ByteString.copyFrom(Hex.fromStringCondensed(groupPublicKey)), + name, + encryptionKeyPair, + membersAsData, + adminsAsData, + expireTimer.toInt() + ) + val closedGroupControlMessage = ClosedGroupControlMessage(closedGroupNewKind, groupID) // It's important that the sent timestamp of this message is greater than the sent timestamp // of the `MembersAdded` message above. The reason is that upon receiving this `New` message, // the recipient will update the closed group formation timestamp and ignore any closed group @@ -207,7 +218,7 @@ fun MessageSender.removeMembers(groupPublicKey: String, membersToRemove: List<St // Send the update to the group val memberUpdateKind = ClosedGroupControlMessage.Kind.MembersRemoved(removeMembersAsData) val sentTime = SnodeAPI.nowWithOffset - val closedGroupControlMessage = ClosedGroupControlMessage(memberUpdateKind) + val closedGroupControlMessage = ClosedGroupControlMessage(memberUpdateKind, groupID) closedGroupControlMessage.sentTimestamp = sentTime send(closedGroupControlMessage, Address.fromSerialized(groupID)) // Send the new encryption key pair to the remaining group members. @@ -236,7 +247,7 @@ fun MessageSender.leave(groupPublicKey: String, notifyUser: Boolean = true): Pro val admins = group.admins.map { it.serialize() } val name = group.title // Send the update to the group - val closedGroupControlMessage = ClosedGroupControlMessage(ClosedGroupControlMessage.Kind.MemberLeft()) + val closedGroupControlMessage = ClosedGroupControlMessage(ClosedGroupControlMessage.Kind.MemberLeft(), groupID) val sentTime = SnodeAPI.nowWithOffset closedGroupControlMessage.sentTimestamp = sentTime storage.setActive(groupID, false) @@ -297,7 +308,7 @@ fun MessageSender.sendEncryptionKeyPair(groupPublicKey: String, newKeyPair: ECKe } val kind = ClosedGroupControlMessage.Kind.EncryptionKeyPair(ByteString.copyFrom(Hex.fromStringCondensed(groupPublicKey)), wrappers) val sentTime = SnodeAPI.nowWithOffset - val closedGroupControlMessage = ClosedGroupControlMessage(kind) + val closedGroupControlMessage = ClosedGroupControlMessage(kind, null) closedGroupControlMessage.sentTimestamp = sentTime return if (force) { val isSync = MessagingModuleConfiguration.shared.storage.getUserPublicKey() == destination @@ -332,6 +343,6 @@ fun MessageSender.sendLatestEncryptionKeyPair(publicKey: String, groupPublicKey: Log.d("Loki", "Sending latest encryption key pair to: $publicKey.") val wrapper = ClosedGroupControlMessage.KeyPairWrapper(publicKey, ByteString.copyFrom(ciphertext)) val kind = ClosedGroupControlMessage.Kind.EncryptionKeyPair(ByteString.copyFrom(Hex.fromStringCondensed(groupPublicKey)), listOf(wrapper)) - val closedGroupControlMessage = ClosedGroupControlMessage(kind) + val closedGroupControlMessage = ClosedGroupControlMessage(kind, groupID) MessageSender.send(closedGroupControlMessage, Address.fromSerialized(publicKey)) } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 19278aadde..6450d927a3 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -2,10 +2,13 @@ package org.session.libsession.messaging.sending_receiving import android.text.TextUtils import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.avatars.AvatarHelper import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.BackgroundGroupAddJob import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.messaging.messages.ExpirationConfiguration.Companion.isNewConfigEnabled import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage @@ -23,7 +26,7 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment 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 @@ -31,8 +34,10 @@ import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.WebRtcUtils import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID import org.session.libsession.utilities.ProfileKeyUtil import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences @@ -148,10 +153,27 @@ fun MessageReceiver.cancelTypingIndicatorsIfNeeded(senderPublicKey: String) { } private fun MessageReceiver.handleExpirationTimerUpdate(message: ExpirationTimerUpdate) { - if (message.duration!! > 0) { - SSKEnvironment.shared.messageExpirationManager.setExpirationTimer(message) - } else { - SSKEnvironment.shared.messageExpirationManager.disableExpirationTimer(message) + SSKEnvironment.shared.messageExpirationManager.insertExpirationTimerMessage(message) + + // TODO (Groups V2 - FIXME) + val isGroupV1 = message.groupPublicKey != null + + if (isNewConfigEnabled && !isGroupV1) return + + val module = MessagingModuleConfiguration.shared + try { + val threadId = fromSerialized(message.groupPublicKey?.let(::doubleEncodeGroupID) ?: message.sender!!) + .let(module.storage::getOrCreateThreadIdFor) + + module.storage.setExpirationConfiguration( + ExpirationConfiguration( + threadId, + message.expiryMode, + message.sentTimestamp!! + ) + ) + } catch (e: Exception) { + Log.e("Loki", "Failed to update expiration configuration.") } } @@ -192,10 +214,10 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { if (allClosedGroupPublicKeys.contains(closedGroup.publicKey)) { // just handle the closed group encryption key pairs to avoid sync'd devices getting out of sync storage.addClosedGroupEncryptionKeyPair(closedGroup.encryptionKeyPair!!, closedGroup.publicKey, message.sentTimestamp!!) - } else if (firstTimeSync) { + } else { // only handle new closed group if it's first time sync handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, closedGroup.publicKey, closedGroup.name, - closedGroup.encryptionKeyPair!!, closedGroup.members, closedGroup.admins, message.sentTimestamp!!, closedGroup.expirationTimer) + closedGroup.encryptionKeyPair!!, closedGroup.members, closedGroup.admins, message.sentTimestamp!!, -1) } } val allV2OpenGroups = storage.getAllOpenGroups().map { it.value.joinURL } @@ -233,12 +255,12 @@ fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): Long? { val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val timestamp = message.timestamp ?: return null val author = message.author ?: return null - val messageIdToDelete = storage.getMessageIdInDatabase(timestamp, author) ?: return null - messageDataProvider.getServerHashForMessage(messageIdToDelete)?.let { serverHash -> + val (messageIdToDelete, mms) = storage.getMessageIdInDatabase(timestamp, author) ?: return null + messageDataProvider.getServerHashForMessage(messageIdToDelete, mms)?.let { serverHash -> SnodeAPI.deleteMessage(author, listOf(serverHash)) } val deletedMessageId = messageDataProvider.updateMessageAsDeleted(timestamp, author) - if (!messageDataProvider.isOutgoingMessage(messageIdToDelete)) { + if (!messageDataProvider.isOutgoingMessage(timestamp)) { SSKEnvironment.shared.notificationManager.updateNotification(context) } @@ -250,6 +272,14 @@ fun handleMessageRequestResponse(message: MessageRequestResponse) { } //endregion +private fun SignalServiceProtos.Content.ExpirationType.expiryMode(durationSeconds: Long) = takeIf { durationSeconds > 0 }?.let { + when (it) { + SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_READ -> ExpiryMode.AfterRead(durationSeconds) + SignalServiceProtos.Content.ExpirationType.DELETE_AFTER_SEND, SignalServiceProtos.Content.ExpirationType.UNKNOWN -> ExpiryMode.AfterSend(durationSeconds) + else -> ExpiryMode.NONE + } +} ?: ExpiryMode.NONE + fun MessageReceiver.handleVisibleMessage( message: VisibleMessage, proto: SignalServiceProtos.Content, @@ -260,6 +290,7 @@ fun MessageReceiver.handleVisibleMessage( ): Long? { val storage = MessagingModuleConfiguration.shared.storage val context = MessagingModuleConfiguration.shared.context + message.takeIf { it.isSenderSelf }?.sentTimestamp?.let { MessagingModuleConfiguration.shared.lastSentTimestampCache.submitTimestamp(threadId, it) } val userPublicKey = storage.getUserPublicKey() val messageSender: String? = message.sender @@ -304,6 +335,21 @@ fun MessageReceiver.handleVisibleMessage( profileManager.setProfilePicture(context, recipient, null, null) } } + + if (userPublicKey != messageSender && !isUserBlindedSender) { + storage.setBlocksCommunityMessageRequests(recipient, message.blocksMessageRequests) + } + + // update the disappearing / legacy banner for the sender + val disappearingState = when { + proto.dataMessage.expireTimer > 0 && !proto.hasExpirationType() -> Recipient.DisappearingState.LEGACY + else -> Recipient.DisappearingState.UPDATED + } + storage.updateDisappearingState( + messageSender, + threadID, + disappearingState + ) } // Parse quote if needed var quoteModel: QuoteModel? = null @@ -344,14 +390,7 @@ fun MessageReceiver.handleVisibleMessage( } } // Parse attachments if needed - val attachments = proto.dataMessage.attachmentsList.mapNotNull { attachmentProto -> - val attachment = Attachment.fromProto(attachmentProto) - if (!attachment.isValid()) { - return@mapNotNull null - } else { - return@mapNotNull attachment - } - } + val attachments = proto.dataMessage.attachmentsList.map(Attachment::fromProto).filter { it.isValid() } // Cancel any typing indicators if needed cancelTypingIndicatorsIfNeeded(message.sender!!) // Parse reaction if needed @@ -372,25 +411,17 @@ fun MessageReceiver.handleVisibleMessage( message.hasMention = listOf(userPublicKey, userBlindedKey) .filterNotNull() .any { key -> - return@any ( - messageText != null && - messageText.contains("@$key") - ) || ( - (quoteModel?.author?.serialize() ?: "") == key - ) + messageText?.contains("@$key") == true || key == (quoteModel?.author?.serialize() ?: "") } // Persist the message message.threadID = threadID - val messageID = - storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, - attachments, runThreadUpdate - ) ?: return null - val openGroupServerID = message.openGroupServerMessageID - if (openGroupServerID != null) { - val isSms = !(message.isMediaMessage() || attachments.isNotEmpty()) - storage.setOpenGroupServerMessageID(messageID, openGroupServerID, threadID, isSms) + val messageID = storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, attachments, runThreadUpdate) ?: return null + message.openGroupServerMessageID?.let { + val isSms = !message.isMediaMessage() && attachments.isEmpty() + storage.setOpenGroupServerMessageID(messageID, it, threadID, isSms) } + SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(message) return messageID } return null @@ -502,17 +533,20 @@ private fun ClosedGroupControlMessage.getPublicKey(): String = kind!!.let { when }} private fun MessageReceiver.handleNewClosedGroup(message: ClosedGroupControlMessage) { + val storage = MessagingModuleConfiguration.shared.storage val kind = message.kind!! as? ClosedGroupControlMessage.Kind.New ?: return val recipient = Recipient.from(MessagingModuleConfiguration.shared.context, Address.fromSerialized(message.sender!!), false) if (!recipient.isApproved && !recipient.isLocalNumber) return Log.e("Loki", "not accepting new closed group from unapproved recipient") val groupPublicKey = kind.publicKey.toByteArray().toHexString() + // hard code check by group public key in the big function because I can't be bothered to do group double decode re-encodej + if ((storage.getThreadIdFor(message.sender!!, groupPublicKey, null, false) ?: -1L) >= 0L) return val members = kind.members.map { it.toByteArray().toHexString() } val admins = kind.admins.map { it.toByteArray().toHexString() } - val expireTimer = kind.expirationTimer - handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, groupPublicKey, kind.name, kind.encryptionKeyPair!!, members, admins, message.sentTimestamp!!, expireTimer) + val expirationTimer = kind.expirationTimer + handleNewClosedGroup(message.sender!!, message.sentTimestamp!!, groupPublicKey, kind.name, kind.encryptionKeyPair!!, members, admins, message.sentTimestamp!!, expirationTimer) } -private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPublicKey: String, name: String, encryptionKeyPair: ECKeyPair, members: List<String>, admins: List<String>, formationTimestamp: Long, expireTimer: Int) { +private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPublicKey: String, name: String, encryptionKeyPair: ECKeyPair, members: List<String>, admins: List<String>, formationTimestamp: Long, expirationTimer: Int) { val context = MessagingModuleConfiguration.shared.context val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey()!! @@ -544,7 +578,7 @@ private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPubli storage.updateTitle(groupID, name) storage.updateMembers(groupID, members.map { Address.fromSerialized(it) }) } else { - storage.createGroup(groupID, name, LinkedList(members.map { Address.fromSerialized(it) }), + storage.createGroup(groupID, name, LinkedList(members.map { fromSerialized(it) }), null, null, LinkedList(admins.map { Address.fromSerialized(it) }), formationTimestamp) } storage.setProfileSharing(Address.fromSerialized(groupID), true) @@ -552,14 +586,16 @@ private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPubli storage.addClosedGroupPublicKey(groupPublicKey) // Store the encryption key pair storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, sentTimestamp) - storage.createInitialConfigGroup(groupPublicKey, name, GroupUtil.createConfigMemberMap(members, admins), formationTimestamp, encryptionKeyPair) - // Set expiration timer - storage.setExpirationTimer(groupID, expireTimer) + storage.createInitialConfigGroup(groupPublicKey, name, GroupUtil.createConfigMemberMap(members, admins), formationTimestamp, encryptionKeyPair, expirationTimer) // Notify the PN server - PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, groupPublicKey, userPublicKey) - // Create thread - val threadId = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) - storage.setThreadDate(threadId, formationTimestamp) + PushRegistryV1.register(device = MessagingModuleConfiguration.shared.device, publicKey = userPublicKey) + // Notify the user + if (userPublicKey == sender && !groupExists) { + val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) + storage.insertOutgoingInfoMessage(context, groupID, SignalServiceGroup.Type.CREATION, name, members, admins, threadID, sentTimestamp) + } else if (userPublicKey != sender) { + storage.insertIncomingInfoMessage(context, sender, groupID, SignalServiceGroup.Type.CREATION, name, members, admins, sentTimestamp) + } // Start polling ClosedGroupPollerV2.shared.startPolling(groupPublicKey) } @@ -865,7 +901,7 @@ fun MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey: String, grou storage.setActive(groupID, false) storage.removeMember(groupID, Address.fromSerialized(userPublicKey)) // Notify the PN server - PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Unsubscribe, groupPublicKey, userPublicKey) + PushRegistryV1.unsubscribeGroup(groupPublicKey, publicKey = userPublicKey) // Stop polling ClosedGroupPollerV2.shared.stopPolling(groupPublicKey) diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/MessageNotifier.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/MessageNotifier.kt index 37480543b7..8de01ca53e 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/MessageNotifier.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/MessageNotifier.kt @@ -14,4 +14,4 @@ interface MessageNotifier { fun updateNotification(context: Context, threadId: Long, signal: Boolean) fun updateNotification(context: Context, signal: Boolean, reminderCount: Int) fun clearReminder(context: Context) -} \ No newline at end of file +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt new file mode 100644 index 0000000000..ea2492d5af --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Models.kt @@ -0,0 +1,126 @@ +package org.session.libsession.messaging.sending_receiving.notifications + +import com.goterl.lazysodium.utils.Key +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * N.B. all of these variable names will be named the same as the actual JSON utf-8 request/responses expected from the server. + * Changing the variable names will break how data is serialized/deserialized. + * If it's less than ideally named we can use [SerialName], such as for the push metadata which uses + * single-letter keys to be as compact as possible. + */ + +@Serializable +data class SubscriptionRequest( + /** the 33-byte account being subscribed to; typically a session ID */ + val pubkey: String, + /** when the pubkey starts with 05 (i.e. a session ID) this is the ed25519 32-byte pubkey associated with the session ID */ + val session_ed25519: String?, + /** 32-byte swarm authentication subkey; omitted (or null) when not using subkey auth (new closed groups) */ + val subkey_tag: String? = null, + /** array of integer namespaces to subscribe to, **must be sorted in ascending order** */ + val namespaces: List<Int>, + /** if provided and true then notifications will include the body of the message (as long as it isn't too large) */ + val data: Boolean, + /** the signature unix timestamp in seconds, not ms */ + val sig_ts: Long, + /** the 64-byte ed25519 signature */ + val signature: String, + /** the string identifying the notification service, "firebase" for android (currently) */ + val service: String, + /** dict of service-specific data, currently just "token" field with device-specific token but different services might have other requirements */ + val service_info: Map<String, String>, + /** 32-byte encryption key; notification payloads sent to the device will be encrypted with XChaCha20-Poly1305 via libsodium using this key. + * persist it on device */ + val enc_key: String +) + +@Serializable +data class UnsubscriptionRequest( + /** the 33-byte account being subscribed to; typically a session ID */ + val pubkey: String, + /** when the pubkey starts with 05 (i.e. a session ID) this is the ed25519 32-byte pubkey associated with the session ID */ + val session_ed25519: String?, + /** 32-byte swarm authentication subkey; omitted (or null) when not using subkey auth (new closed groups) */ + val subkey_tag: String? = null, + /** the signature unix timestamp in seconds, not ms */ + val sig_ts: Long, + /** the 64-byte ed25519 signature */ + val signature: String, + /** the string identifying the notification service, "firebase" for android (currently) */ + val service: String, + /** dict of service-specific data, currently just "token" field with device-specific token but different services might have other requirements */ + val service_info: Map<String, String>, +) + +/** invalid values, missing reuqired arguments etc, details in message */ +private const val UNPARSEABLE_ERROR = 1 +/** the "service" value is not active / valid */ +private const val SERVICE_NOT_AVAILABLE = 2 +/** something getting wrong internally talking to the backend */ +private const val SERVICE_TIMEOUT = 3 +/** other error processing the subscription (details in the message) */ +private const val GENERIC_ERROR = 4 + +@Serializable +data class SubscriptionResponse( + override val error: Int? = null, + override val message: String? = null, + override val success: Boolean? = null, + val added: Boolean? = null, + val updated: Boolean? = null, +): Response + +@Serializable +data class UnsubscribeResponse( + override val error: Int? = null, + override val message: String? = null, + override val success: Boolean? = null, + val removed: Boolean? = null, +): Response + +interface Response { + val error: Int? + val message: String? + val success: Boolean? + fun isSuccess() = success == true && error == null + fun isFailure() = !isSuccess() +} + +@Serializable +data class PushNotificationMetadata( + /** Account ID (such as Session ID or closed group ID) where the message arrived **/ + @SerialName("@") + val account: String, + + /** The hash of the message in the swarm. */ + @SerialName("#") + val msg_hash: String, + + /** The swarm namespace in which this message arrived. */ + @SerialName("n") + val namespace: Int, + + /** The length of the message data. This is always included, even if the message content + * itself was too large to fit into the push notification. */ + @SerialName("l") + val data_len: Int, + + /** This will be true if the data was omitted because it was too long to fit in a push + * notification (around 2.5kB of raw data), in which case the push notification includes + * only this metadata but not the message content itself. */ + @SerialName("B") + val data_too_long : Boolean = false +) + +@Serializable +data class PushNotificationServerObject( + val enc_payload: String, + val spns: Int, +) { + fun decryptPayload(key: Key): Any { + + TODO() + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt deleted file mode 100644 index f793cd6e4b..0000000000 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.session.libsession.messaging.sending_receiving.notifications - -import android.annotation.SuppressLint -import nl.komponents.kovenant.functional.map -import okhttp3.MediaType -import okhttp3.Request -import okhttp3.RequestBody -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.Version -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.retryIfNeeded -import org.session.libsignal.utilities.JsonUtil -import org.session.libsignal.utilities.Log - -@SuppressLint("StaticFieldLeak") -object PushNotificationAPI { - val context = MessagingModuleConfiguration.shared.context - val server = "https://live.apns.getsession.org" - val serverPublicKey = "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049" - private val maxRetryCount = 4 - private val tokenExpirationInterval = 12 * 60 * 60 * 1000 - - enum class ClosedGroupOperation { - Subscribe, Unsubscribe; - - val rawValue: String - get() { - return when (this) { - Subscribe -> "subscribe_closed_group" - Unsubscribe -> "unsubscribe_closed_group" - } - } - } - - fun unregister(token: String) { - 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) { - OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, Version.V2).map { response -> - val code = response.info["code"] as? Int - if (code != null && code != 0) { - TextSecurePreferences.setIsUsingFCM(context, false) - } else { - Log.d("Loki", "Couldn't disable FCM due to error: ${response.info["message"] as? String ?: "null"}.") - } - }.fail { exception -> - Log.d("Loki", "Couldn't disable FCM due to error: ${exception}.") - } - } - // Unsubscribe from all closed groups - val allClosedGroupPublicKeys = MessagingModuleConfiguration.shared.storage.getAllClosedGroupPublicKeys() - val userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! - allClosedGroupPublicKeys.iterator().forEach { closedGroup -> - performOperation(ClosedGroupOperation.Unsubscribe, closedGroup, userPublicKey) - } - } - - fun register(token: String, publicKey: String, 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) { - OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, Version.V2).map { response -> - val code = response.info["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: ${response.info["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 = MessagingModuleConfiguration.shared.storage.getAllClosedGroupPublicKeys() - allClosedGroupPublicKeys.iterator().forEach { closedGroup -> - performOperation(ClosedGroupOperation.Subscribe, closedGroup, publicKey) - } - } - - fun performOperation(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) { - OnionRequestAPI.sendOnionRequest(request.build(), server, serverPublicKey, Version.V2).map { response -> - val code = response.info["code"] as? Int - if (code == null || code == 0) { - Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${response.info["message"] as? String ?: "null"}.") - } - }.fail { exception -> - Log.d("Loki", "Couldn't subscribe/unsubscribe closed group: $closedGroupPublicKey due to error: ${exception}.") - } - } - } -} diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt new file mode 100644 index 0000000000..1599dd93d5 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushRegistryV1.kt @@ -0,0 +1,141 @@ +package org.session.libsession.messaging.sending_receiving.notifications + +import android.annotation.SuppressLint +import nl.komponents.kovenant.Promise +import okhttp3.MediaType +import okhttp3.Request +import okhttp3.RequestBody +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.OnionResponse +import org.session.libsession.snode.Version +import org.session.libsession.utilities.Device +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.emptyPromise +import org.session.libsignal.utilities.retryIfNeeded +import org.session.libsignal.utilities.sideEffect + +@SuppressLint("StaticFieldLeak") +object PushRegistryV1 { + private val TAG = PushRegistryV1::class.java.name + + val context = MessagingModuleConfiguration.shared.context + private const val maxRetryCount = 4 + + private val server = Server.LEGACY + + fun register( + device: Device, + isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context), + token: String? = TextSecurePreferences.getPushToken(context), + publicKey: String? = TextSecurePreferences.getLocalNumber(context), + legacyGroupPublicKeys: Collection<String> = MessagingModuleConfiguration.shared.storage.getAllClosedGroupPublicKeys() + ): Promise<*, Exception> = when { + isPushEnabled -> retryIfNeeded(maxRetryCount) { + Log.d(TAG, "register() called") + doRegister(token, publicKey, device, legacyGroupPublicKeys) + } fail { exception -> + Log.d(TAG, "Couldn't register for FCM due to error", exception) + } + + else -> emptyPromise() + } + + private fun doRegister(token: String?, publicKey: String?, device: Device, legacyGroupPublicKeys: Collection<String>): Promise<*, Exception> { + Log.d(TAG, "doRegister() called") + + token ?: return emptyPromise() + publicKey ?: return emptyPromise() + + val parameters = mapOf( + "token" to token, + "pubKey" to publicKey, + "device" to device.value, + "legacyGroupPublicKeys" to legacyGroupPublicKeys + ) + + val url = "${server.url}/register_legacy_groups_only" + val body = RequestBody.create( + MediaType.get("application/json"), + JsonUtil.toJson(parameters) + ) + val request = Request.Builder().url(url).post(body).build() + + return sendOnionRequest(request) sideEffect { response -> + when (response.code) { + null, 0 -> throw Exception("error: ${response.message}.") + } + } success { + Log.d(TAG, "registerV1 success") + } + } + + /** + * Unregister push notifications for 1-1 conversations as this is now done in FirebasePushManager. + */ + fun unregister(): Promise<*, Exception> { + Log.d(TAG, "unregisterV1 requested") + + val token = TextSecurePreferences.getPushToken(context) ?: emptyPromise() + + return retryIfNeeded(maxRetryCount) { + val parameters = mapOf("token" to token) + val url = "${server.url}/unregister" + val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) + val request = Request.Builder().url(url).post(body).build() + + sendOnionRequest(request) success { + when (it.code) { + null, 0 -> Log.d(TAG, "error: ${it.message}.") + else -> Log.d(TAG, "unregisterV1 success") + } + } + } + } + + // Legacy Closed Groups + + fun subscribeGroup( + closedGroupPublicKey: String, + isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context), + publicKey: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! + ) = if (isPushEnabled) { + performGroupOperation("subscribe_closed_group", closedGroupPublicKey, publicKey) + } else emptyPromise() + + fun unsubscribeGroup( + closedGroupPublicKey: String, + isPushEnabled: Boolean = TextSecurePreferences.isPushEnabled(context), + publicKey: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! + ) = if (isPushEnabled) { + performGroupOperation("unsubscribe_closed_group", closedGroupPublicKey, publicKey) + } else emptyPromise() + + private fun performGroupOperation( + operation: String, + closedGroupPublicKey: String, + publicKey: String + ): Promise<*, Exception> { + val parameters = mapOf("closedGroupPublicKey" to closedGroupPublicKey, "pubKey" to publicKey) + val url = "${server.url}/$operation" + val body = RequestBody.create(MediaType.get("application/json"), JsonUtil.toJson(parameters)) + val request = Request.Builder().url(url).post(body).build() + + return retryIfNeeded(maxRetryCount) { + sendOnionRequest(request) sideEffect { + when (it.code) { + 0, null -> throw Exception(it.message) + } + } + } + } + + private fun sendOnionRequest(request: Request): Promise<OnionResponse, Exception> = OnionRequestAPI.sendOnionRequest( + request, + server.url, + server.publicKey, + Version.V2 + ) +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Server.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Server.kt new file mode 100644 index 0000000000..0497fe2220 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/Server.kt @@ -0,0 +1,6 @@ +package org.session.libsession.messaging.sending_receiving.notifications + +enum class Server(val url: String, val publicKey: String) { + LATEST("https://push.getsession.org", "d7557fe563e2610de876c0ac7341b62f3c82d5eea4b62c702392ea4368f51b3b"), + LEGACY("https://live.apns.getsession.org", "642a6585919742e5a2d4dc51244964fbcd8bcab2b75612407de58b810740d049") +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index f0b20436fc..39ed79de1e 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -152,14 +152,18 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti Log.w("Loki", "shared config message handled in configs wasn't SharedConfigurationMessage but was ${message.javaClass.simpleName}") return@forEach } - forConfigObject.merge(hash!! to message.data) - latestMessageTimestamp = if ((message.sentTimestamp ?: 0L) > (latestMessageTimestamp ?: 0L)) { message.sentTimestamp } else { latestMessageTimestamp } + val merged = forConfigObject.merge(hash!! to message.data).firstOrNull { it == hash } + if (merged != null) { + // We successfully merged the hash, we can now update the timestamp + latestMessageTimestamp = if ((message.sentTimestamp ?: 0L) > (latestMessageTimestamp ?: 0L)) { message.sentTimestamp } else { latestMessageTimestamp } + } } catch (e: Exception) { Log.e("Loki", e) } } // process new results - if (forConfigObject.needsDump()) { + // latestMessageTimestamp should always be non-null if the config object needs dump + if (forConfigObject.needsDump() && latestMessageTimestamp != null) { configFactory.persist(forConfigObject, latestMessageTimestamp ?: SnodeAPI.nowWithOffset) } } @@ -210,7 +214,6 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti val responseList = (rawResponses["results"] as List<RawResponse>) // in case we had null configs, the array won't be fully populated // index of the sparse array key iterator should be the request index, with the key being the namespace - // TODO: add in specific ordering of config namespaces for processing listOfNotNull( configFactory.user?.configNamespace(), configFactory.contacts?.configNamespace(), diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt index 9a1de4f2d5..38e6080950 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/SodiumUtilities.kt @@ -14,7 +14,7 @@ import org.whispersystems.curve25519.Curve25519 import kotlin.experimental.xor object SodiumUtilities { - private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } + val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } private val curve by lazy { Curve25519.getInstance(Curve25519.BEST) } private const val SCALAR_LENGTH: Int = 32 // crypto_core_ed25519_scalarbytes @@ -205,7 +205,7 @@ object SodiumUtilities { } fun decrypt(ciphertext: ByteArray, decryptionKey: ByteArray, nonce: ByteArray): ByteArray? { - val plaintextSize = ciphertext.size - AEAD.CHACHA20POLY1305_ABYTES + val plaintextSize = ciphertext.size - AEAD.XCHACHA20POLY1305_IETF_ABYTES val plaintext = ByteArray(plaintextSize) return if (sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt( plaintext, diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt index 6cf18ba5c5..5533b236ed 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/UpdateMessageBuilder.kt @@ -1,6 +1,7 @@ package org.session.libsession.messaging.utilities import android.content.Context +import android.util.Log import org.session.libsession.R import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.calls.CallMessageType @@ -10,13 +11,16 @@ import org.session.libsession.messaging.calls.CallMessageType.CALL_MISSED import org.session.libsession.messaging.calls.CallMessageType.CALL_OUTGOING import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage +import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED +import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage.Kind.SCREENSHOT import org.session.libsession.utilities.ExpirationUtil +import org.session.libsession.utilities.getExpirationTypeDisplayValue import org.session.libsession.utilities.truncateIdForDisplay object UpdateMessageBuilder { val storage = MessagingModuleConfiguration.shared.storage - fun getSenderName(senderId: String) = storage.getContactWithSessionID(senderId) + private fun getSenderName(senderId: String) = storage.getContactWithSessionID(senderId) ?.displayName(Contact.ContactContext.REGULAR) ?: truncateIdForDisplay(senderId) @@ -27,76 +31,74 @@ object UpdateMessageBuilder { else getSenderName(senderId!!) return when (updateData) { - is UpdateMessageData.Kind.GroupCreation -> if (isOutgoing) { - context.getString(R.string.MessageRecord_you_created_a_new_group) - } else { - context.getString(R.string.MessageRecord_s_added_you_to_the_group, senderName) + is UpdateMessageData.Kind.GroupCreation -> { + if (isOutgoing) context.getString(R.string.MessageRecord_you_created_a_new_group) + else context.getString(R.string.MessageRecord_s_added_you_to_the_group, senderName) } - is UpdateMessageData.Kind.GroupNameChange -> if (isOutgoing) { - context.getString(R.string.MessageRecord_you_renamed_the_group_to_s, updateData.name) - } else { - context.getString(R.string.MessageRecord_s_renamed_the_group_to_s, senderName, updateData.name) + is UpdateMessageData.Kind.GroupNameChange -> { + if (isOutgoing) context.getString(R.string.MessageRecord_you_renamed_the_group_to_s, updateData.name) + else context.getString(R.string.MessageRecord_s_renamed_the_group_to_s, senderName, updateData.name) } is UpdateMessageData.Kind.GroupMemberAdded -> { val members = updateData.updatedMembers.joinToString(", ", transform = ::getSenderName) - if (isOutgoing) { - context.getString(R.string.MessageRecord_you_added_s_to_the_group, members) - } else { - context.getString(R.string.MessageRecord_s_added_s_to_the_group, senderName, members) - } + if (isOutgoing) context.getString(R.string.MessageRecord_you_added_s_to_the_group, members) + else context.getString(R.string.MessageRecord_s_added_s_to_the_group, senderName, members) } is UpdateMessageData.Kind.GroupMemberRemoved -> { val userPublicKey = storage.getUserPublicKey()!! // 1st case: you are part of the removed members return if (userPublicKey in updateData.updatedMembers) { - if (isOutgoing) { - context.getString(R.string.MessageRecord_left_group) - } else { - context.getString(R.string.MessageRecord_you_were_removed_from_the_group) - } + if (isOutgoing) context.getString(R.string.MessageRecord_left_group) + else context.getString(R.string.MessageRecord_you_were_removed_from_the_group) } else { // 2nd case: you are not part of the removed members val members = updateData.updatedMembers.joinToString(", ", transform = ::getSenderName) - if (isOutgoing) { - context.getString(R.string.MessageRecord_you_removed_s_from_the_group, members) - } else { - context.getString(R.string.MessageRecord_s_removed_s_from_the_group, senderName, members) - } + if (isOutgoing) context.getString(R.string.MessageRecord_you_removed_s_from_the_group, members) + else context.getString(R.string.MessageRecord_s_removed_s_from_the_group, senderName, members) } } - is UpdateMessageData.Kind.GroupMemberLeft -> if (isOutgoing) { - context.getString(R.string.MessageRecord_left_group) - } else { - context.getString(R.string.ConversationItem_group_action_left, senderName) + is UpdateMessageData.Kind.GroupMemberLeft -> { + if (isOutgoing) context.getString(R.string.MessageRecord_left_group) + else context.getString(R.string.ConversationItem_group_action_left, senderName) } else -> return "" } } - fun buildExpirationTimerMessage(context: Context, duration: Long, senderId: String? = null, isOutgoing: Boolean = false): String { + fun buildExpirationTimerMessage( + context: Context, + duration: Long, + isGroup: Boolean, + senderId: String? = null, + isOutgoing: Boolean = false, + timestamp: Long, + expireStarted: Long + ): String { if (!isOutgoing && senderId == null) return "" - val senderName: String = if (!isOutgoing) { - getSenderName(senderId!!) - } else { context.getString(R.string.MessageRecord_you) } + val senderName = if (isOutgoing) context.getString(R.string.MessageRecord_you) else getSenderName(senderId!!) return if (duration <= 0) { - if (isOutgoing) context.getString(R.string.MessageRecord_you_disabled_disappearing_messages) - else context.getString(R.string.MessageRecord_s_disabled_disappearing_messages, senderName) + if (isOutgoing) context.getString(if (isGroup) R.string.MessageRecord_you_turned_off_disappearing_messages else R.string.MessageRecord_you_turned_off_disappearing_messages_1_on_1) + else context.getString(if (isGroup) R.string.MessageRecord_s_turned_off_disappearing_messages else R.string.MessageRecord_s_turned_off_disappearing_messages_1_on_1, senderName) } else { val time = ExpirationUtil.getExpirationDisplayValue(context, duration.toInt()) - if (isOutgoing)context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time) - else context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, senderName, time) + val action = context.getExpirationTypeDisplayValue(timestamp >= expireStarted) + if (isOutgoing) context.getString( + if (isGroup) R.string.MessageRecord_you_set_messages_to_disappear_s_after_s else R.string.MessageRecord_you_set_messages_to_disappear_s_after_s_1_on_1, + time, + action + ) else context.getString( + if (isGroup) R.string.MessageRecord_s_set_messages_to_disappear_s_after_s else R.string.MessageRecord_s_set_messages_to_disappear_s_after_s_1_on_1, + senderName, + time, + action + ) } } - fun buildDataExtractionMessage(context: Context, kind: DataExtractionNotificationInfoMessage.Kind, senderId: String? = null): String { - val senderName = getSenderName(senderId!!) - return when (kind) { - DataExtractionNotificationInfoMessage.Kind.SCREENSHOT -> - context.getString(R.string.MessageRecord_s_took_a_screenshot, senderName) - DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED -> - context.getString(R.string.MessageRecord_media_saved_by_s, senderName) - } - } + fun buildDataExtractionMessage(context: Context, kind: DataExtractionNotificationInfoMessage.Kind, senderId: String? = null) = when (kind) { + SCREENSHOT -> R.string.MessageRecord_s_took_a_screenshot + MEDIA_SAVED -> R.string.MessageRecord_media_saved_by_s + }.let { context.getString(it, getSenderName(senderId!!)) } fun buildCallMessage(context: Context, type: CallMessageType, sender: String): String = when (type) { diff --git a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt index 8851dfc2b3..04b0f722c1 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -611,7 +611,7 @@ object OnionRequestAPI { } if (body["t"] != null) { val timestamp = body["t"] as Long - val offset = timestamp - Date().time + val offset = timestamp - System.currentTimeMillis() SnodeAPI.clockOffset = offset } if (body.containsKey("hf")) { @@ -686,4 +686,7 @@ enum class Version(val value: String) { data class OnionResponse( val info: Map<*, *>, val body: ByteArray? = null -) +) { + val code: Int? get() = info["code"] as? Int + val message: String? get() = info["message"] as? String +} diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt index b1a274773c..0f996bacac 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -3,8 +3,6 @@ package org.session.libsession.snode import android.os.Build -import com.goterl.lazysodium.LazySodiumAndroid -import com.goterl.lazysodium.SodiumAndroid import com.goterl.lazysodium.exceptions.SodiumException import com.goterl.lazysodium.interfaces.GenericHash import com.goterl.lazysodium.interfaces.PwHash @@ -19,6 +17,7 @@ import nl.komponents.kovenant.functional.map import nl.komponents.kovenant.task import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.utilities.MessageWrapper +import org.session.libsession.messaging.utilities.SodiumUtilities.sodium import org.session.libsignal.crypto.getRandomElement import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.protos.SignalServiceProtos @@ -41,7 +40,6 @@ import kotlin.collections.set import kotlin.properties.Delegates.observable object SnodeAPI { - private val sodium by lazy { LazySodiumAndroid(SodiumAndroid()) } internal val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage private val broadcaster: Broadcaster @@ -533,14 +531,10 @@ object SnodeAPI { fun getExpiries(messageHashes: List<String>, publicKey: String) : RawResponsePromise { val userEd25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return Promise.ofFail(NullPointerException("No user key pair")) + val hashes = messageHashes.takeIf { it.size != 1 } ?: (messageHashes + "///////////////////////////////////////////") // TODO remove this when bug is fixed on nodes. return retryIfNeeded(maxRetryCount) { val timestamp = System.currentTimeMillis() + clockOffset - val params = mutableMapOf( - "pubkey" to publicKey, - "messages" to messageHashes, - "timestamp" to timestamp - ) - val signData = "${Snode.Method.GetExpiries.rawValue}$timestamp${messageHashes.joinToString(separator = "")}".toByteArray() + val signData = "${Snode.Method.GetExpiries.rawValue}$timestamp${hashes.joinToString(separator = "")}".toByteArray() val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString val signature = ByteArray(Sign.BYTES) @@ -555,9 +549,14 @@ object SnodeAPI { Log.e("Loki", "Signing data failed with user secret key", e) return@retryIfNeeded Promise.ofFail(e) } - params["pubkey_ed25519"] = ed25519PublicKey - params["signature"] = Base64.encodeBytes(signature) - getSingleTargetSnode(publicKey).bind { snode -> + val params = mapOf( + "pubkey" to publicKey, + "messages" to hashes, + "timestamp" to timestamp, + "pubkey_ed25519" to ed25519PublicKey, + "signature" to Base64.encodeBytes(signature) + ) + getSingleTargetSnode(publicKey) bind { snode -> invoke(Snode.Method.GetExpiries, snode, params, publicKey) } } @@ -761,6 +760,62 @@ object SnodeAPI { } } + fun updateExpiry(updatedExpiryMs: Long, serverHashes: List<String>): Promise<Map<String, Pair<List<String>, Long>>, Exception> { + return retryIfNeeded(maxRetryCount) { + val module = MessagingModuleConfiguration.shared + val userED25519KeyPair = module.getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) + val userPublicKey = module.storage.getUserPublicKey() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) + val updatedExpiryMsWithNetworkOffset = updatedExpiryMs + clockOffset + getSingleTargetSnode(userPublicKey).bind { snode -> + retryIfNeeded(maxRetryCount) { + // "expire" || expiry || messages[0] || ... || messages[N] + val verificationData = + (Snode.Method.Expire.rawValue + updatedExpiryMsWithNetworkOffset + serverHashes.fold("") { a, v -> a + v }).toByteArray() + val signature = ByteArray(Sign.BYTES) + sodium.cryptoSignDetached( + signature, + verificationData, + verificationData.size.toLong(), + userED25519KeyPair.secretKey.asBytes + ) + val params = mapOf( + "pubkey" to userPublicKey, + "pubkey_ed25519" to userED25519KeyPair.publicKey.asHexString, + "expiry" to updatedExpiryMs, + "messages" to serverHashes, + "signature" to Base64.encodeBytes(signature) + ) + invoke(Snode.Method.Expire, snode, params, userPublicKey).map { rawResponse -> + val swarms = rawResponse["swarm"] as? Map<String, Any> ?: return@map mapOf() + val result = swarms.mapNotNull { (hexSnodePublicKey, rawJSON) -> + val json = rawJSON as? Map<String, Any> ?: return@mapNotNull null + val isFailed = json["failed"] as? Boolean ?: false + val statusCode = json["code"] as? String + val reason = json["reason"] as? String + hexSnodePublicKey to if (isFailed) { + Log.e("Loki", "Failed to update expiry for: $hexSnodePublicKey due to error: $reason ($statusCode).") + listOf<String>() to 0L + } else { + val hashes = json["updated"] as List<String> + val expiryApplied = json["expiry"] as Long + val signature = json["signature"] as String + val snodePublicKey = Key.fromHexString(hexSnodePublicKey) + // The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) + val message = (userPublicKey + serverHashes.fold("") { a, v -> a + v } + hashes.fold("") { a, v -> a + v }).toByteArray() + if (sodium.cryptoSignVerifyDetached(Base64.decode(signature), message, message.size, snodePublicKey.asBytes)) { + hashes to expiryApplied + } else listOf<String>() to 0L + } + } + return@map result.toMap() + }.fail { e -> + Log.e("Loki", "Failed to update expiry", e) + } + } + } + } + } + fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0, updateLatestHash: Boolean = true, updateStoredHashes: Boolean = true): List<Pair<SignalServiceProtos.Envelope, String?>> { val messages = rawResponse["messages"] as? List<*> return if (messages != null) { diff --git a/libsession/src/main/java/org/session/libsession/utilities/Address.kt b/libsession/src/main/java/org/session/libsession/utilities/Address.kt index c8cd11d4b6..920b466a8b 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Address.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/Address.kt @@ -8,7 +8,6 @@ import androidx.annotation.VisibleForTesting import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Util import org.session.libsignal.utilities.guava.Optional -import java.util.Collections import java.util.LinkedList import java.util.concurrent.atomic.AtomicReference import java.util.regex.Matcher @@ -23,17 +22,17 @@ class Address private constructor(address: String) : Parcelable, Comparable<Addr get() = GroupUtil.isEncodedGroup(address) val isClosedGroup: Boolean get() = GroupUtil.isClosedGroup(address) - val isOpenGroup: Boolean - get() = GroupUtil.isOpenGroup(address) - val isOpenGroupInbox: Boolean - get() = GroupUtil.isOpenGroupInbox(address) - val isOpenGroupOutbox: Boolean + val isCommunity: Boolean + get() = GroupUtil.isCommunity(address) + val isCommunityInbox: Boolean + get() = GroupUtil.isCommunityInbox(address) + val isCommunityOutbox: Boolean get() = address.startsWith(IdPrefix.BLINDED.value) || address.startsWith(IdPrefix.BLINDEDV2.value) val isContact: Boolean - get() = !(isGroup || isOpenGroupInbox) + get() = !(isGroup || isCommunityInbox) fun contactIdentifier(): String { - if (!isContact && !isOpenGroup) { + if (!isContact && !isCommunity) { if (isGroup) throw AssertionError("Not e164, is group") throw AssertionError("Not e164, unknown") } @@ -168,8 +167,9 @@ class Address private constructor(address: String) : Parcelable, Comparable<Addr @JvmStatic fun fromSerializedList(serialized: String, delimiter: Char): List<Address> { val escapedAddresses = DelimiterUtil.split(serialized, delimiter) + val set = escapedAddresses.toSet().sorted() val addresses: MutableList<Address> = LinkedList() - for (escapedAddress in escapedAddresses) { + for (escapedAddress in set) { addresses.add(fromSerialized(DelimiterUtil.unescape(escapedAddress, delimiter))) } return addresses @@ -177,9 +177,9 @@ class Address private constructor(address: String) : Parcelable, Comparable<Addr @JvmStatic fun toSerializedList(addresses: List<Address>, delimiter: Char): String { - Collections.sort(addresses) + val set = addresses.toSet().sorted() val escapedAddresses: MutableList<String> = LinkedList() - for (address in addresses) { + for (address in set) { escapedAddresses.add(DelimiterUtil.escape(address.serialize(), delimiter)) } return Util.join(escapedAddresses, delimiter.toString() + "") diff --git a/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt b/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt index a45fcd6761..8add1da849 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt @@ -19,5 +19,5 @@ interface ConfigFactoryProtocol { } interface ConfigFactoryUpdateListener { - fun notifyUpdates(forConfigObject: ConfigBase) + fun notifyUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/Device.kt b/libsession/src/main/java/org/session/libsession/utilities/Device.kt new file mode 100644 index 0000000000..c00e0a69dd --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/Device.kt @@ -0,0 +1,6 @@ +package org.session.libsession.utilities + +enum class Device(val value: String, val service: String = value) { + ANDROID("android", "firebase"), + HUAWEI("huawei"); +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.java b/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.java deleted file mode 100644 index 88130f3f95..0000000000 --- a/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.session.libsession.utilities; - -import android.content.Context; - -import java.util.concurrent.TimeUnit; - -import org.session.libsession.R; - -public class ExpirationUtil { - - public static String getExpirationDisplayValue(Context context, int expirationTime) { - if (expirationTime <= 0) { - return context.getString(R.string.expiration_off); - } else if (expirationTime < TimeUnit.MINUTES.toSeconds(1)) { - return context.getResources().getQuantityString(R.plurals.expiration_seconds, expirationTime, expirationTime); - } else if (expirationTime < TimeUnit.HOURS.toSeconds(1)) { - int minutes = expirationTime / (int)TimeUnit.MINUTES.toSeconds(1); - return context.getResources().getQuantityString(R.plurals.expiration_minutes, minutes, minutes); - } else if (expirationTime < TimeUnit.DAYS.toSeconds(1)) { - int hours = expirationTime / (int)TimeUnit.HOURS.toSeconds(1); - return context.getResources().getQuantityString(R.plurals.expiration_hours, hours, hours); - } else if (expirationTime < TimeUnit.DAYS.toSeconds(7)) { - int days = expirationTime / (int)TimeUnit.DAYS.toSeconds(1); - return context.getResources().getQuantityString(R.plurals.expiration_days, days, days); - } else { - int weeks = expirationTime / (int)TimeUnit.DAYS.toSeconds(7); - return context.getResources().getQuantityString(R.plurals.expiration_weeks, weeks, weeks); - } - } - - public static String getExpirationAbbreviatedDisplayValue(Context context, int expirationTime) { - if (expirationTime < TimeUnit.MINUTES.toSeconds(1)) { - return context.getResources().getString(R.string.expiration_seconds_abbreviated, expirationTime); - } else if (expirationTime < TimeUnit.HOURS.toSeconds(1)) { - int minutes = expirationTime / (int)TimeUnit.MINUTES.toSeconds(1); - return context.getResources().getString(R.string.expiration_minutes_abbreviated, minutes); - } else if (expirationTime < TimeUnit.DAYS.toSeconds(1)) { - int hours = expirationTime / (int)TimeUnit.HOURS.toSeconds(1); - return context.getResources().getString(R.string.expiration_hours_abbreviated, hours); - } else if (expirationTime < TimeUnit.DAYS.toSeconds(7)) { - int days = expirationTime / (int)TimeUnit.DAYS.toSeconds(1); - return context.getResources().getString(R.string.expiration_days_abbreviated, days); - } else { - int weeks = expirationTime / (int)TimeUnit.DAYS.toSeconds(7); - return context.getResources().getString(R.string.expiration_weeks_abbreviated, weeks); - } - } - - -} diff --git a/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.kt b/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.kt new file mode 100644 index 0000000000..f3647fa4f8 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.kt @@ -0,0 +1,57 @@ +package org.session.libsession.utilities + +import android.content.Context +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.R +import java.util.concurrent.TimeUnit +import kotlin.time.Duration + +fun Context.getExpirationTypeDisplayValue(sent: Boolean) = if (sent) getString(R.string.MessageRecord_state_sent) else getString(R.string.MessageRecord_state_read) + +object ExpirationUtil { + @JvmStatic + fun getExpirationDisplayValue(context: Context, duration: Duration): String = getExpirationDisplayValue(context, duration.inWholeSeconds.toInt()) + + @JvmStatic + fun getExpirationDisplayValue(context: Context, expirationTime: Int): String { + return if (expirationTime <= 0) { + context.getString(R.string.expiration_off) + } else if (expirationTime < TimeUnit.MINUTES.toSeconds(1)) { + context.resources.getQuantityString( + R.plurals.expiration_seconds, + expirationTime, + expirationTime + ) + } else if (expirationTime < TimeUnit.HOURS.toSeconds(1)) { + val minutes = expirationTime / TimeUnit.MINUTES.toSeconds(1).toInt() + context.resources.getQuantityString(R.plurals.expiration_minutes, minutes, minutes) + } else if (expirationTime < TimeUnit.DAYS.toSeconds(1)) { + val hours = expirationTime / TimeUnit.HOURS.toSeconds(1).toInt() + context.resources.getQuantityString(R.plurals.expiration_hours, hours, hours) + } else if (expirationTime < TimeUnit.DAYS.toSeconds(7)) { + val days = expirationTime / TimeUnit.DAYS.toSeconds(1).toInt() + context.resources.getQuantityString(R.plurals.expiration_days, days, days) + } else { + val weeks = expirationTime / TimeUnit.DAYS.toSeconds(7).toInt() + context.resources.getQuantityString(R.plurals.expiration_weeks, weeks, weeks) + } + } + + fun getExpirationAbbreviatedDisplayValue(context: Context, expirationTime: Long): String { + return if (expirationTime < TimeUnit.MINUTES.toSeconds(1)) { + context.resources.getString(R.string.expiration_seconds_abbreviated, expirationTime) + } else if (expirationTime < TimeUnit.HOURS.toSeconds(1)) { + val minutes = expirationTime / TimeUnit.MINUTES.toSeconds(1) + context.resources.getString(R.string.expiration_minutes_abbreviated, minutes) + } else if (expirationTime < TimeUnit.DAYS.toSeconds(1)) { + val hours = expirationTime / TimeUnit.HOURS.toSeconds(1) + context.resources.getString(R.string.expiration_hours_abbreviated, hours) + } else if (expirationTime < TimeUnit.DAYS.toSeconds(7)) { + val days = expirationTime / TimeUnit.DAYS.toSeconds(1) + context.resources.getString(R.string.expiration_days_abbreviated, days) + } else { + val weeks = expirationTime / TimeUnit.DAYS.toSeconds(7) + context.resources.getString(R.string.expiration_weeks_abbreviated, weeks) + } + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/GroupRecord.kt b/libsession/src/main/java/org/session/libsession/utilities/GroupRecord.kt index 630e6a89ef..34b17f16b9 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/GroupRecord.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/GroupRecord.kt @@ -22,7 +22,7 @@ class GroupRecord( } val isOpenGroup: Boolean - get() = Address.fromSerialized(encodedId).isOpenGroup + get() = Address.fromSerialized(encodedId).isCommunity val isClosedGroup: Boolean get() = Address.fromSerialized(encodedId).isClosedGroup @@ -34,4 +34,4 @@ class GroupRecord( this.admins = Address.fromSerializedList(admins!!, ',') } } -} \ No newline at end of file +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt b/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt index bfab2585de..9c30aeb249 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/GroupUtil.kt @@ -1,23 +1,31 @@ package org.session.libsession.utilities -import network.loki.messenger.libsession_util.util.GroupInfo +import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.messaging.utilities.SessionId import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.utilities.Hex import java.io.IOException object GroupUtil { const val CLOSED_GROUP_PREFIX = "__textsecure_group__!" - const val OPEN_GROUP_PREFIX = "__loki_public_chat_group__!" - const val OPEN_GROUP_INBOX_PREFIX = "__open_group_inbox__!" + const val COMMUNITY_PREFIX = "__loki_public_chat_group__!" + const val COMMUNITY_INBOX_PREFIX = "__open_group_inbox__!" @JvmStatic fun getEncodedOpenGroupID(groupID: ByteArray): String { - return OPEN_GROUP_PREFIX + Hex.toStringCondensed(groupID) + return COMMUNITY_PREFIX + Hex.toStringCondensed(groupID) } @JvmStatic - fun getEncodedOpenGroupInboxID(groupInboxID: ByteArray): String { - return OPEN_GROUP_INBOX_PREFIX + Hex.toStringCondensed(groupInboxID) + fun getEncodedOpenGroupInboxID(openGroup: OpenGroup, sessionId: SessionId): Address { + val openGroupInboxId = + "${openGroup.server}!${openGroup.publicKey}!${sessionId.hexString}".toByteArray() + return getEncodedOpenGroupInboxID(openGroupInboxId) + } + + @JvmStatic + fun getEncodedOpenGroupInboxID(groupInboxID: ByteArray): Address { + return Address.fromSerialized(COMMUNITY_INBOX_PREFIX + Hex.toStringCondensed(groupInboxID)) } @JvmStatic @@ -52,7 +60,7 @@ object GroupUtil { } @JvmStatic - fun getDecodedOpenGroupInbox(groupID: String): String { + fun getDecodedOpenGroupInboxSessionId(groupID: String): String { val decodedGroupId = getDecodedGroupID(groupID) if (decodedGroupId.split("!").count() > 2) { return decodedGroupId.split("!", limit = 3)[2] @@ -61,17 +69,17 @@ object GroupUtil { } fun isEncodedGroup(groupId: String): Boolean { - return groupId.startsWith(CLOSED_GROUP_PREFIX) || groupId.startsWith(OPEN_GROUP_PREFIX) + return groupId.startsWith(CLOSED_GROUP_PREFIX) || groupId.startsWith(COMMUNITY_PREFIX) } @JvmStatic - fun isOpenGroup(groupId: String): Boolean { - return groupId.startsWith(OPEN_GROUP_PREFIX) + fun isCommunity(groupId: String): Boolean { + return groupId.startsWith(COMMUNITY_PREFIX) } @JvmStatic - fun isOpenGroupInbox(groupId: String): Boolean { - return groupId.startsWith(OPEN_GROUP_INBOX_PREFIX) + fun isCommunityInbox(groupId: String): Boolean { + return groupId.startsWith(COMMUNITY_INBOX_PREFIX) } @JvmStatic @@ -100,22 +108,23 @@ object GroupUtil { @JvmStatic @Throws(IOException::class) - fun doubleDecodeGroupId(groupID: String): String { - return Hex.toStringCondensed(getDecodedGroupIDAsData(getDecodedGroupID(groupID))) - } + fun doubleDecodeGroupId(groupID: String): String = + Hex.toStringCondensed(getDecodedGroupIDAsData(getDecodedGroupID(groupID))) + + @JvmStatic + fun addressToGroupSessionId(address: Address): String = + doubleDecodeGroupId(address.toGroupString()) fun createConfigMemberMap( members: Collection<String>, admins: Collection<String> ): Map<String, Boolean> { // Start with admins - val memberMap = admins.associate { - it to true - }.toMutableMap() + val memberMap = admins.associateWith { true }.toMutableMap() // Add the remaining members (there may be duplicates, so only add ones that aren't already in there from admins) for (member in members) { - if (!memberMap.contains(member)) { + if (member !in memberMap) { memberMap[member] = false } } diff --git a/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt b/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt index f647cc0f48..5915d5c4ee 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/SSKEnvironment.kt @@ -1,9 +1,15 @@ package org.session.libsession.utilities import android.content.Context +import android.util.Log +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier +import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.utilities.recipients.Recipient class SSKEnvironment( @@ -37,9 +43,37 @@ class SSKEnvironment( } interface MessageExpirationManagerProtocol { - fun setExpirationTimer(message: ExpirationTimerUpdate) - fun disableExpirationTimer(message: ExpirationTimerUpdate) - fun startAnyExpiration(timestamp: Long, author: String) + fun insertExpirationTimerMessage(message: ExpirationTimerUpdate) + fun startAnyExpiration(timestamp: Long, author: String, expireStartedAt: Long) + + fun maybeStartExpiration(message: Message, startDisappearAfterRead: Boolean = false) { + if (message is ExpirationTimerUpdate && message.isGroup || message is ClosedGroupControlMessage) return + + maybeStartExpiration( + message.sentTimestamp ?: return, + message.sender ?: return, + message.expiryMode, + startDisappearAfterRead || message.isSenderSelf + ) + } + + fun startDisappearAfterRead(timestamp: Long, sender: String) { + startAnyExpiration( + timestamp, + sender, + expireStartedAt = nowWithOffset.coerceAtLeast(timestamp + 1) + ) + } + + fun maybeStartExpiration(timestamp: Long, sender: String, mode: ExpiryMode, startDisappearAfterRead: Boolean = false) { + val expireStartedAt = when (mode) { + is ExpiryMode.AfterSend -> timestamp + is ExpiryMode.AfterRead -> if (startDisappearAfterRead) nowWithOffset.coerceAtLeast(timestamp + 1) else return + else -> return + } + + startAnyExpiration(timestamp, sender, expireStartedAt) + } } companion object { diff --git a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index d6ed963735..af16d93f5a 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -37,12 +37,12 @@ interface TextSecurePreferences { fun setLastConfigurationSyncTime(value: Long) fun getConfigurationMessageSynced(): Boolean fun setConfigurationMessageSynced(value: Boolean) - fun isUsingFCM(): Boolean - fun setIsUsingFCM(value: Boolean) - fun getFCMToken(): String? - fun setFCMToken(value: String) - fun getLastFCMUploadTime(): Long - fun setLastFCMUploadTime(value: Long) + fun isPushEnabled(): Boolean + fun setPushEnabled(value: Boolean) + fun getPushToken(): String? + fun setPushToken(value: String) + fun getPushRegisterTime(): Long + fun setPushRegisterTime(value: Long) fun isScreenLockEnabled(): Boolean fun setScreenLockEnabled(value: Boolean) fun getScreenLockTimeout(): Long @@ -189,6 +189,9 @@ interface TextSecurePreferences { internal val _events = MutableSharedFlow<String>(0, 64, BufferOverflow.DROP_OLDEST) val events get() = _events.asSharedFlow() + @JvmStatic + var pushSuffix = "" + const val DISABLE_PASSPHRASE_PREF = "pref_disable_passphrase" const val LANGUAGE_PREF = "pref_language" const val THREAD_TRIM_NOW = "pref_trim_now" @@ -251,9 +254,9 @@ interface TextSecurePreferences { const val LINK_PREVIEWS = "pref_link_previews" const val GIF_METADATA_WARNING = "has_seen_gif_metadata_warning" const val GIF_GRID_LAYOUT = "pref_gif_grid_layout" - const val IS_USING_FCM = "pref_is_using_fcm" - const val FCM_TOKEN = "pref_fcm_token" - const val LAST_FCM_TOKEN_UPLOAD_TIME = "pref_last_fcm_token_upload_time_2" + val IS_PUSH_ENABLED get() = "pref_is_using_fcm$pushSuffix" + val PUSH_TOKEN get() = "pref_fcm_token_2$pushSuffix" + val PUSH_REGISTER_TIME get() = "pref_last_fcm_token_upload_time_2$pushSuffix" const val LAST_CONFIGURATION_SYNC_TIME = "pref_last_configuration_sync_time" const val CONFIGURATION_SYNCED = "pref_configuration_synced" const val LAST_PROFILE_UPDATE_TIME = "pref_last_profile_update_time" @@ -287,6 +290,8 @@ interface TextSecurePreferences { const val OCEAN_DARK = "ocean.dark" const val OCEAN_LIGHT = "ocean.light" + const val ALLOW_MESSAGE_REQUESTS = "libsession.ALLOW_MESSAGE_REQUESTS" + @JvmStatic fun getLastConfigurationSyncTime(context: Context): Long { return getLongPreference(context, LAST_CONFIGURATION_SYNC_TIME, 0) @@ -309,31 +314,31 @@ interface TextSecurePreferences { } @JvmStatic - fun isUsingFCM(context: Context): Boolean { - return getBooleanPreference(context, IS_USING_FCM, false) + fun isPushEnabled(context: Context): Boolean { + return getBooleanPreference(context, IS_PUSH_ENABLED, false) } @JvmStatic - fun setIsUsingFCM(context: Context, value: Boolean) { - setBooleanPreference(context, IS_USING_FCM, value) + fun setPushEnabled(context: Context, value: Boolean) { + setBooleanPreference(context, IS_PUSH_ENABLED, value) } @JvmStatic - fun getFCMToken(context: Context): String? { - return getStringPreference(context, FCM_TOKEN, "") + fun getPushToken(context: Context): String? { + return getStringPreference(context, PUSH_TOKEN, "") } @JvmStatic - fun setFCMToken(context: Context, value: String) { - setStringPreference(context, FCM_TOKEN, value) + fun setPushToken(context: Context, value: String?) { + setStringPreference(context, PUSH_TOKEN, value) } - fun getLastFCMUploadTime(context: Context): Long { - return getLongPreference(context, LAST_FCM_TOKEN_UPLOAD_TIME, 0) + fun getPushRegisterTime(context: Context): Long { + return getLongPreference(context, PUSH_REGISTER_TIME, 0) } - fun setLastFCMUploadTime(context: Context, value: Long) { - setLongPreference(context, LAST_FCM_TOKEN_UPLOAD_TIME, value) + fun setPushRegisterTime(context: Context, value: Long) { + setLongPreference(context, PUSH_REGISTER_TIME, value) } // endregion @@ -837,7 +842,7 @@ interface TextSecurePreferences { getDefaultSharedPreferences(context).edit().putString(key, value).apply() } - private fun getIntegerPreference(context: Context, key: String, defaultValue: Int): Int { + fun getIntegerPreference(context: Context, key: String, defaultValue: Int): Int { return getDefaultSharedPreferences(context).getInt(key, defaultValue) } @@ -1008,7 +1013,6 @@ interface TextSecurePreferences { fun clearAll(context: Context) { getDefaultSharedPreferences(context).edit().clear().commit() } - } } @@ -1033,28 +1037,28 @@ class AppTextSecurePreferences @Inject constructor( TextSecurePreferences._events.tryEmit(TextSecurePreferences.CONFIGURATION_SYNCED) } - override fun isUsingFCM(): Boolean { - return getBooleanPreference(TextSecurePreferences.IS_USING_FCM, false) + override fun isPushEnabled(): Boolean { + return getBooleanPreference(TextSecurePreferences.IS_PUSH_ENABLED, false) } - override fun setIsUsingFCM(value: Boolean) { - setBooleanPreference(TextSecurePreferences.IS_USING_FCM, value) + override fun setPushEnabled(value: Boolean) { + setBooleanPreference(TextSecurePreferences.IS_PUSH_ENABLED, value) } - override fun getFCMToken(): String? { - return getStringPreference(TextSecurePreferences.FCM_TOKEN, "") + override fun getPushToken(): String? { + return getStringPreference(TextSecurePreferences.PUSH_TOKEN, "") } - override fun setFCMToken(value: String) { - setStringPreference(TextSecurePreferences.FCM_TOKEN, value) + override fun setPushToken(value: String) { + setStringPreference(TextSecurePreferences.PUSH_TOKEN, value) } - override fun getLastFCMUploadTime(): Long { - return getLongPreference(TextSecurePreferences.LAST_FCM_TOKEN_UPLOAD_TIME, 0) + override fun getPushRegisterTime(): Long { + return getLongPreference(TextSecurePreferences.PUSH_REGISTER_TIME, 0) } - override fun setLastFCMUploadTime(value: Long) { - setLongPreference(TextSecurePreferences.LAST_FCM_TOKEN_UPLOAD_TIME, value) + override fun setPushRegisterTime(value: Long) { + setLongPreference(TextSecurePreferences.PUSH_REGISTER_TIME, value) } override fun isScreenLockEnabled(): Boolean { diff --git a/libsession/src/main/java/org/session/libsession/utilities/ThemeUtil.java b/libsession/src/main/java/org/session/libsession/utilities/ThemeUtil.java index dc77aae5ac..22375b1da7 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/ThemeUtil.java +++ b/libsession/src/main/java/org/session/libsession/utilities/ThemeUtil.java @@ -27,6 +27,10 @@ public class ThemeUtil { return getAttributeText(context, R.attr.theme_type, "light").equals("dark"); } + public static boolean isLightTheme(@NonNull Context context) { + return getAttributeText(context, R.attr.theme_type, "light").equals("light"); + } + public static boolean getThemedBoolean(@NonNull Context context, @AttrRes int attr) { TypedValue typedValue = new TypedValue(); Resources.Theme theme = context.getTheme(); diff --git a/libsession/src/main/java/org/session/libsession/utilities/Util.kt b/libsession/src/main/java/org/session/libsession/utilities/Util.kt index 80a8d3282d..17009caa7d 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Util.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/Util.kt @@ -365,4 +365,21 @@ object Util { val digitGroups = (Math.log10(sizeBytes.toDouble()) / Math.log10(1024.0)).toInt() return DecimalFormat("#,##0.#").format(sizeBytes / Math.pow(1024.0, digitGroups.toDouble())) + " " + units[digitGroups] } -} \ No newline at end of file +} + +fun <T, R> T.runIf(condition: Boolean, block: T.() -> R): R where T: R = if (condition) block() else this + +fun <T, K: Any> Iterable<T>.associateByNotNull( + keySelector: (T) -> K? +) = associateByNotNull(keySelector) { it } + +fun <T, K: Any, V: Any> Iterable<T>.associateByNotNull( + keySelector: (T) -> K?, + valueTransform: (T) -> V?, +): Map<K, V> = buildMap { + for (item in this@associateByNotNull) { + val key = keySelector(item) ?: continue + val value = valueTransform(item) ?: continue + this[key] = value + } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/ViewUtils.kt b/libsession/src/main/java/org/session/libsession/utilities/ViewUtils.kt index 80c0293238..7be7224f3a 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/ViewUtils.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/ViewUtils.kt @@ -2,6 +2,8 @@ package org.session.libsession.utilities import android.content.Context import android.util.TypedValue +import android.view.View +import android.view.ViewGroup import androidx.annotation.AttrRes import androidx.annotation.ColorInt @@ -14,3 +16,7 @@ fun Context.getColorFromAttr( theme.resolveAttribute(attrColor, typedValue, resolveRefs) return typedValue.data } + +inline fun <reified LP: ViewGroup.LayoutParams> View.modifyLayoutParams(function: LP.() -> Unit) { + layoutParams = (layoutParams as LP).apply { function() } +} diff --git a/libsession/src/main/java/org/session/libsession/utilities/bencode/Bencode.kt b/libsession/src/main/java/org/session/libsession/utilities/bencode/Bencode.kt new file mode 100644 index 0000000000..427e80691a --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/bencode/Bencode.kt @@ -0,0 +1,169 @@ +package org.session.libsession.utilities.bencode + +import java.util.LinkedList + +object Bencode { + class Decoder(source: ByteArray) { + + private val iterator = LinkedList<Byte>().apply { + addAll(source.asIterable()) + } + + /** + * Decode an element based on next marker assumed to be string/int/list/dict or return null + */ + fun decode(): BencodeElement? { + val result = when (iterator.peek()?.toInt()?.toChar()) { + in NUMBERS -> decodeString() + INT_INDICATOR -> decodeInt() + LIST_INDICATOR -> decodeList() + DICT_INDICATOR -> decodeDict() + else -> { + null + } + } + return result + } + + /** + * Decode a string element from iterator assumed to have structure `{length}:{data}` + */ + private fun decodeString(): BencodeString? { + val lengthStrings = buildString { + while (iterator.isNotEmpty() && iterator.peek()?.toInt()?.toChar() != SEPARATOR) { + append(iterator.pop().toInt().toChar()) + } + } + iterator.pop() // drop `:` + val length = lengthStrings.toIntOrNull(10) ?: return null + val remaining = (0 until length).map { iterator.pop() }.toByteArray() + return BencodeString(remaining) + } + + /** + * Decode an int element from iterator assumed to have structure `i{int}e` + */ + private fun decodeInt(): BencodeElement? { + iterator.pop() // drop `i` + val intString = buildString { + while (iterator.isNotEmpty() && iterator.peek()?.toInt()?.toChar() != END_INDICATOR) { + append(iterator.pop().toInt().toChar()) + } + } + val asInt = intString.toIntOrNull(10) ?: return null + iterator.pop() // drop `e` + return BencodeInteger(asInt) + } + + /** + * Decode a list element from iterator assumed to have structure `l{data}e` + */ + private fun decodeList(): BencodeElement { + iterator.pop() // drop `l` + val listElements = mutableListOf<BencodeElement>() + while (iterator.isNotEmpty() && iterator.peek()?.toInt()?.toChar() != END_INDICATOR) { + decode()?.let { nextElement -> + listElements += nextElement + } + } + iterator.pop() // drop `e` + return BencodeList(listElements) + } + + /** + * Decode a dict element from iterator assumed to have structure `d{data}e` + */ + private fun decodeDict(): BencodeElement? { + iterator.pop() // drop `d` + val dictElements = mutableMapOf<String,BencodeElement>() + while (iterator.isNotEmpty() && iterator.peek()?.toInt()?.toChar() != END_INDICATOR) { + val key = decodeString() ?: return null + val value = decode() ?: return null + dictElements += key.value.decodeToString() to value + } + iterator.pop() // drop `e` + return BencodeDict(dictElements) + } + + companion object { + private val NUMBERS = arrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9') + private const val INT_INDICATOR = 'i' + private const val LIST_INDICATOR = 'l' + private const val DICT_INDICATOR = 'd' + private const val END_INDICATOR = 'e' + private const val SEPARATOR = ':' + } + + } + +} + +sealed class BencodeElement { + abstract fun encode(): ByteArray +} + +fun String.bencode() = BencodeString(this.encodeToByteArray()) +fun Int.bencode() = BencodeInteger(this) + +data class BencodeString(val value: ByteArray): BencodeElement() { + override fun encode(): ByteArray = buildString { + append(value.size.toString()) + append(':') + }.toByteArray() + value + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as BencodeString + + if (!value.contentEquals(other.value)) return false + + return true + } + + override fun hashCode(): Int { + return value.contentHashCode() + } +} +data class BencodeInteger(val value: Int): BencodeElement() { + override fun encode(): ByteArray = buildString { + append('i') + append(value.toString()) + append('e') + }.toByteArray() +} +data class BencodeList(val values: List<BencodeElement>): BencodeElement() { + + constructor(vararg values: BencodeElement) : this(values.toList()) + + override fun encode(): ByteArray = "l".toByteArray() + + values.fold(byteArrayOf()) { array, element -> array + element.encode() } + + "e".toByteArray() +} +data class BencodeDict(val values: Map<String, BencodeElement>): BencodeElement() { + + constructor(vararg values: Pair<String, BencodeElement>) : this(values.toMap()) + + override fun encode(): ByteArray = "d".toByteArray() + + values.entries.fold(byteArrayOf()) { array, (key, value) -> + array + key.bencode().encode() + value.encode() + } + "e".toByteArray() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as BencodeDict + + if (values != other.values) return false + + return true + } + + override fun hashCode(): Int { + return values.hashCode() + } + + +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java b/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java index e2d193a934..0601f3c1e9 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java +++ b/libsession/src/main/java/org/session/libsession/utilities/recipients/Recipient.java @@ -70,7 +70,7 @@ public class Recipient implements RecipientModifiedListener { private final @NonNull Address address; private final @NonNull List<Recipient> participants = new LinkedList<>(); - private Context context; + private final Context context; private @Nullable String name; private @Nullable String customLabel; private boolean resolving; @@ -86,6 +86,7 @@ public class Recipient implements RecipientModifiedListener { private boolean blocked = false; private boolean approved = false; private boolean approvedMe = false; + private DisappearingState disappearingState = null; private VibrateState messageVibrate = VibrateState.DEFAULT; private VibrateState callVibrate = VibrateState.DEFAULT; private int expireMessages = 0; @@ -100,6 +101,7 @@ public class Recipient implements RecipientModifiedListener { private String notificationChannel; private boolean forceSmsSelection; private String wrapperHash; + private boolean blocksCommunityMessageRequests; private @NonNull UnidentifiedAccessMode unidentifiedAccessMode = UnidentifiedAccessMode.ENABLED; @@ -130,7 +132,7 @@ public class Recipient implements RecipientModifiedListener { @NonNull Optional<RecipientDetails> details, @NonNull ListenableFutureTask<RecipientDetails> future) { - this.context = context; + this.context = context.getApplicationContext(); this.address = address; this.color = null; this.resolving = true; @@ -162,6 +164,7 @@ public class Recipient implements RecipientModifiedListener { this.unidentifiedAccessMode = stale.unidentifiedAccessMode; this.forceSmsSelection = stale.forceSmsSelection; this.notifyType = stale.notifyType; + this.disappearingState = stale.disappearingState; this.participants.clear(); this.participants.addAll(stale.participants); @@ -192,6 +195,8 @@ public class Recipient implements RecipientModifiedListener { this.unidentifiedAccessMode = details.get().unidentifiedAccessMode; this.forceSmsSelection = details.get().forceSmsSelection; this.notifyType = details.get().notifyType; + this.blocksCommunityMessageRequests = details.get().blocksCommunityMessageRequests; + this.disappearingState = details.get().disappearingState; this.participants.clear(); this.participants.addAll(details.get().participants); @@ -228,6 +233,8 @@ public class Recipient implements RecipientModifiedListener { Recipient.this.unidentifiedAccessMode = result.unidentifiedAccessMode; Recipient.this.forceSmsSelection = result.forceSmsSelection; Recipient.this.notifyType = result.notifyType; + Recipient.this.disappearingState = result.disappearingState; + Recipient.this.blocksCommunityMessageRequests = result.blocksCommunityMessageRequests; Recipient.this.participants.clear(); Recipient.this.participants.addAll(result.participants); @@ -252,7 +259,7 @@ public class Recipient implements RecipientModifiedListener { } Recipient(@NonNull Context context, @NonNull Address address, @NonNull RecipientDetails details) { - this.context = context; + this.context = context.getApplicationContext(); this.address = address; this.contactUri = details.contactUri; this.name = details.name; @@ -281,6 +288,7 @@ public class Recipient implements RecipientModifiedListener { this.unidentifiedAccessMode = details.unidentifiedAccessMode; this.forceSmsSelection = details.forceSmsSelection; this.wrapperHash = details.wrapperHash; + this.blocksCommunityMessageRequests = details.blocksCommunityMessageRequests; this.participants.addAll(details.participants); this.resolving = false; @@ -321,7 +329,7 @@ public class Recipient implements RecipientModifiedListener { return this.name; } } else if (isOpenGroupInboxRecipient()){ - String inboxID = GroupUtil.getDecodedOpenGroupInbox(sessionID); + String inboxID = GroupUtil.getDecodedOpenGroupInboxSessionId(sessionID); Contact contact = storage.getContactWithSessionID(inboxID); if (contact == null) { return sessionID; } return contact.displayName(Contact.ContactContext.REGULAR); @@ -345,6 +353,18 @@ public class Recipient implements RecipientModifiedListener { if (notify) notifyListeners(); } + public boolean getBlocksCommunityMessageRequests() { + return blocksCommunityMessageRequests; + } + + public void setBlocksCommunityMessageRequests(boolean blocksCommunityMessageRequests) { + synchronized (this) { + this.blocksCommunityMessageRequests = blocksCommunityMessageRequests; + } + + notifyListeners(); + } + public synchronized @NonNull MaterialColor getColor() { if (isGroupRecipient()) return MaterialColor.GROUP; else if (color != null) return color; @@ -437,17 +457,18 @@ public class Recipient implements RecipientModifiedListener { public boolean isContactRecipient() { return address.isContact(); } + public boolean is1on1() { return address.isContact() && !isLocalNumber; } - public boolean isOpenGroupRecipient() { - return address.isOpenGroup(); + public boolean isCommunityRecipient() { + return address.isCommunity(); } public boolean isOpenGroupOutboxRecipient() { - return address.isOpenGroupOutbox(); + return address.isCommunityOutbox(); } public boolean isOpenGroupInboxRecipient() { - return address.isOpenGroupInbox(); + return address.isCommunityInbox(); } public boolean isClosedGroupRecipient() { @@ -665,6 +686,18 @@ public class Recipient implements RecipientModifiedListener { notifyListeners(); } + public synchronized DisappearingState getDisappearingState() { + return disappearingState; + } + + public void setDisappearingState(DisappearingState disappearingState) { + synchronized (this) { + this.disappearingState = disappearingState; + } + + notifyListeners(); + } + public synchronized RegisteredState getRegistered() { if (isPushGroupRecipient()) return RegisteredState.REGISTERED; @@ -754,17 +787,52 @@ public class Recipient implements RecipientModifiedListener { return this; } + public synchronized boolean showCallMenu() { + return !isGroupRecipient() && hasApprovedMe(); + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Recipient recipient = (Recipient) o; - return resolving == recipient.resolving && mutedUntil == recipient.mutedUntil && notifyType == recipient.notifyType && blocked == recipient.blocked && approved == recipient.approved && approvedMe == recipient.approvedMe && expireMessages == recipient.expireMessages && address.equals(recipient.address) && Objects.equals(name, recipient.name) && Objects.equals(customLabel, recipient.customLabel) && Objects.equals(groupAvatarId, recipient.groupAvatarId) && Arrays.equals(profileKey, recipient.profileKey) && Objects.equals(profileName, recipient.profileName) && Objects.equals(profileAvatar, recipient.profileAvatar) && Objects.equals(wrapperHash, recipient.wrapperHash); + return resolving == recipient.resolving + && mutedUntil == recipient.mutedUntil + && notifyType == recipient.notifyType + && blocked == recipient.blocked + && approved == recipient.approved + && approvedMe == recipient.approvedMe + && expireMessages == recipient.expireMessages + && address.equals(recipient.address) + && Objects.equals(name, recipient.name) + && Objects.equals(customLabel, recipient.customLabel) + && Objects.equals(groupAvatarId, recipient.groupAvatarId) + && Arrays.equals(profileKey, recipient.profileKey) + && Objects.equals(profileName, recipient.profileName) + && Objects.equals(profileAvatar, recipient.profileAvatar) + && Objects.equals(wrapperHash, recipient.wrapperHash) + && blocksCommunityMessageRequests == recipient.blocksCommunityMessageRequests; } @Override public int hashCode() { - int result = Objects.hash(address, name, customLabel, resolving, groupAvatarId, mutedUntil, notifyType, blocked, approved, approvedMe, expireMessages, profileName, profileAvatar, wrapperHash); + int result = Objects.hash( + address, + name, + customLabel, + resolving, + groupAvatarId, + mutedUntil, + notifyType, + blocked, + approved, + approvedMe, + expireMessages, + profileName, + profileAvatar, + wrapperHash, + blocksCommunityMessageRequests + ); result = 31 * result + Arrays.hashCode(profileKey); return result; } @@ -807,6 +875,24 @@ public class Recipient implements RecipientModifiedListener { } } + public enum DisappearingState { + LEGACY(0), UPDATED(1); + + private final int id; + + DisappearingState(int id) { + this.id = id; + } + + public int getId() { + return id; + } + + public static DisappearingState fromId(int id) { + return values()[id]; + } + } + public enum RegisteredState { UNKNOWN(0), REGISTERED(1), NOT_REGISTERED(2); @@ -849,6 +935,7 @@ public class Recipient implements RecipientModifiedListener { private final boolean approvedMe; private final long muteUntil; private final int notifyType; + private final DisappearingState disappearingState; private final VibrateState messageVibrateState; private final VibrateState callVibrateState; private final Uri messageRingtone; @@ -869,55 +956,61 @@ public class Recipient implements RecipientModifiedListener { private final UnidentifiedAccessMode unidentifiedAccessMode; private final boolean forceSmsSelection; private final String wrapperHash; + private final boolean blocksCommunityMessageRequests; public RecipientSettings(boolean blocked, boolean approved, boolean approvedMe, long muteUntil, - int notifyType, + int notifyType, + @NonNull DisappearingState disappearingState, @NonNull VibrateState messageVibrateState, - @NonNull VibrateState callVibrateState, - @Nullable Uri messageRingtone, - @Nullable Uri callRingtone, - @Nullable MaterialColor color, - int defaultSubscriptionId, - int expireMessages, - @NonNull RegisteredState registered, - @Nullable byte[] profileKey, - @Nullable String systemDisplayName, - @Nullable String systemContactPhoto, - @Nullable String systemPhoneLabel, - @Nullable String systemContactUri, - @Nullable String signalProfileName, - @Nullable String signalProfileAvatar, - boolean profileSharing, - @Nullable String notificationChannel, - @NonNull UnidentifiedAccessMode unidentifiedAccessMode, - boolean forceSmsSelection, - String wrapperHash) + @NonNull VibrateState callVibrateState, + @Nullable Uri messageRingtone, + @Nullable Uri callRingtone, + @Nullable MaterialColor color, + int defaultSubscriptionId, + int expireMessages, + @NonNull RegisteredState registered, + @Nullable byte[] profileKey, + @Nullable String systemDisplayName, + @Nullable String systemContactPhoto, + @Nullable String systemPhoneLabel, + @Nullable String systemContactUri, + @Nullable String signalProfileName, + @Nullable String signalProfileAvatar, + boolean profileSharing, + @Nullable String notificationChannel, + @NonNull UnidentifiedAccessMode unidentifiedAccessMode, + boolean forceSmsSelection, + String wrapperHash, + boolean blocksCommunityMessageRequests + ) { - this.blocked = blocked; - this.approved = approved; - this.approvedMe = approvedMe; - this.muteUntil = muteUntil; - this.notifyType = notifyType; - this.messageVibrateState = messageVibrateState; - this.callVibrateState = callVibrateState; - this.messageRingtone = messageRingtone; - this.callRingtone = callRingtone; - this.color = color; - this.defaultSubscriptionId = defaultSubscriptionId; - this.expireMessages = expireMessages; - this.registered = registered; - this.profileKey = profileKey; - this.systemDisplayName = systemDisplayName; - this.systemContactPhoto = systemContactPhoto; - this.systemPhoneLabel = systemPhoneLabel; - this.systemContactUri = systemContactUri; - this.signalProfileName = signalProfileName; - this.signalProfileAvatar = signalProfileAvatar; - this.profileSharing = profileSharing; - this.notificationChannel = notificationChannel; - this.unidentifiedAccessMode = unidentifiedAccessMode; - this.forceSmsSelection = forceSmsSelection; - this.wrapperHash = wrapperHash; + this.blocked = blocked; + this.approved = approved; + this.approvedMe = approvedMe; + this.muteUntil = muteUntil; + this.notifyType = notifyType; + this.disappearingState = disappearingState; + this.messageVibrateState = messageVibrateState; + this.callVibrateState = callVibrateState; + this.messageRingtone = messageRingtone; + this.callRingtone = callRingtone; + this.color = color; + this.defaultSubscriptionId = defaultSubscriptionId; + this.expireMessages = expireMessages; + this.registered = registered; + this.profileKey = profileKey; + this.systemDisplayName = systemDisplayName; + this.systemContactPhoto = systemContactPhoto; + this.systemPhoneLabel = systemPhoneLabel; + this.systemContactUri = systemContactUri; + this.signalProfileName = signalProfileName; + this.signalProfileAvatar = signalProfileAvatar; + this.profileSharing = profileSharing; + this.notificationChannel = notificationChannel; + this.unidentifiedAccessMode = unidentifiedAccessMode; + this.forceSmsSelection = forceSmsSelection; + this.wrapperHash = wrapperHash; + this.blocksCommunityMessageRequests = blocksCommunityMessageRequests; } public @Nullable MaterialColor getColor() { @@ -944,6 +1037,10 @@ public class Recipient implements RecipientModifiedListener { return notifyType; } + public @NonNull DisappearingState getDisappearingState() { + return disappearingState; + } + public @NonNull VibrateState getMessageVibrateState() { return messageVibrateState; } @@ -1020,6 +1117,10 @@ public class Recipient implements RecipientModifiedListener { return wrapperHash; } + public boolean getBlocksCommunityMessageRequests() { + return blocksCommunityMessageRequests; + } + } diff --git a/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java b/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java index 75ebd837b6..374956072b 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java +++ b/libsession/src/main/java/org/session/libsession/utilities/recipients/RecipientProvider.java @@ -31,6 +31,7 @@ import org.session.libsession.utilities.ListenableFutureTask; import org.session.libsession.utilities.MaterialColor; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; +import org.session.libsession.utilities.recipients.Recipient.DisappearingState; import org.session.libsession.utilities.recipients.Recipient.RecipientSettings; import org.session.libsession.utilities.recipients.Recipient.RegisteredState; import org.session.libsession.utilities.recipients.Recipient.UnidentifiedAccessMode; @@ -159,6 +160,7 @@ class RecipientProvider { @Nullable final Uri callRingtone; final long mutedUntil; final int notifyType; + @Nullable final DisappearingState disappearingState; @Nullable final VibrateState messageVibrateState; @Nullable final VibrateState callVibrateState; final boolean blocked; @@ -178,6 +180,7 @@ class RecipientProvider { @NonNull final UnidentifiedAccessMode unidentifiedAccessMode; final boolean forceSmsSelection; final String wrapperHash; + final boolean blocksCommunityMessageRequests; RecipientDetails(@Nullable String name, @Nullable Long groupAvatarId, boolean systemContact, boolean isLocalNumber, @Nullable RecipientSettings settings, @@ -192,6 +195,7 @@ class RecipientProvider { this.callRingtone = settings != null ? settings.getCallRingtone() : null; this.mutedUntil = settings != null ? settings.getMuteUntil() : 0; this.notifyType = settings != null ? settings.getNotifyType() : 0; + this.disappearingState = settings != null ? settings.getDisappearingState() : null; this.messageVibrateState = settings != null ? settings.getMessageVibrateState() : null; this.callVibrateState = settings != null ? settings.getCallVibrateState() : null; this.blocked = settings != null && settings.isBlocked(); @@ -211,6 +215,7 @@ class RecipientProvider { this.unidentifiedAccessMode = settings != null ? settings.getUnidentifiedAccessMode() : UnidentifiedAccessMode.DISABLED; this.forceSmsSelection = settings != null && settings.isForceSmsSelection(); this.wrapperHash = settings != null ? settings.getWrapperHash() : null; + this.blocksCommunityMessageRequests = settings != null && settings.getBlocksCommunityMessageRequests(); if (name == null && settings != null) this.name = settings.getSystemDisplayName(); else this.name = name; diff --git a/libsession/src/main/res/values-fr-rFR/strings.xml b/libsession/src/main/res/values-fr-rFR/strings.xml index c8324290f7..0298ae07f0 100644 --- a/libsession/src/main/res/values-fr-rFR/strings.xml +++ b/libsession/src/main/res/values-fr-rFR/strings.xml @@ -15,10 +15,6 @@ <string name="MessageRecord_s_called_you">%s vous a appelé·e</string> <string name="MessageRecord_called_s">Vous avez appelĂ© %s</string> <string name="MessageRecord_missed_call_from">Appel manquĂ© de %s</string> - <string name="MessageRecord_you_disabled_disappearing_messages">Vous avez dĂ©sactivĂ© les messages Ă©phĂ©mĂšres.</string> - <string name="MessageRecord_s_disabled_disappearing_messages">%1$s a dĂ©sactivĂ© les messages Ă©phĂ©mĂšres.</string> - <string name="MessageRecord_you_set_disappearing_message_time_to_s">Vous avez dĂ©fini l’expiration des messages Ă©phĂ©mĂšres Ă  %1$s</string> - <string name="MessageRecord_s_set_disappearing_message_time_to_s">%1$s a dĂ©fini l’expiration des messages Ă©phĂ©mĂšres Ă  %2$s</string> <string name="MessageRecord_s_took_a_screenshot">%1$s a pris une capture d\'Ă©cran.</string> <string name="MessageRecord_media_saved_by_s">%1$s a enregistrĂ© le mĂ©dia.</string> <!-- expiration --> diff --git a/libsession/src/main/res/values-fr/strings.xml b/libsession/src/main/res/values-fr/strings.xml index c8324290f7..0298ae07f0 100644 --- a/libsession/src/main/res/values-fr/strings.xml +++ b/libsession/src/main/res/values-fr/strings.xml @@ -15,10 +15,6 @@ <string name="MessageRecord_s_called_you">%s vous a appelé·e</string> <string name="MessageRecord_called_s">Vous avez appelĂ© %s</string> <string name="MessageRecord_missed_call_from">Appel manquĂ© de %s</string> - <string name="MessageRecord_you_disabled_disappearing_messages">Vous avez dĂ©sactivĂ© les messages Ă©phĂ©mĂšres.</string> - <string name="MessageRecord_s_disabled_disappearing_messages">%1$s a dĂ©sactivĂ© les messages Ă©phĂ©mĂšres.</string> - <string name="MessageRecord_you_set_disappearing_message_time_to_s">Vous avez dĂ©fini l’expiration des messages Ă©phĂ©mĂšres Ă  %1$s</string> - <string name="MessageRecord_s_set_disappearing_message_time_to_s">%1$s a dĂ©fini l’expiration des messages Ă©phĂ©mĂšres Ă  %2$s</string> <string name="MessageRecord_s_took_a_screenshot">%1$s a pris une capture d\'Ă©cran.</string> <string name="MessageRecord_media_saved_by_s">%1$s a enregistrĂ© le mĂ©dia.</string> <!-- expiration --> diff --git a/libsession/src/main/res/values/attrs.xml b/libsession/src/main/res/values/attrs.xml index 64fab0950e..474fe565e0 100644 --- a/libsession/src/main/res/values/attrs.xml +++ b/libsession/src/main/res/values/attrs.xml @@ -252,11 +252,6 @@ <attr name="doc_downloadButtonTint" format="color" /> </declare-styleable> - <declare-styleable name="ConversationItemFooter"> - <attr name="footer_text_color" format="color" /> - <attr name="footer_icon_color" format="color" /> - </declare-styleable> - <declare-styleable name="ConversationItemThumbnail"> <attr name="conversationThumbnail_minWidth" format="dimension" /> <attr name="conversationThumbnail_maxWidth" format="dimension" /> diff --git a/libsession/src/main/res/values/strings.xml b/libsession/src/main/res/values/strings.xml index c9904920f2..7e4ffed396 100644 --- a/libsession/src/main/res/values/strings.xml +++ b/libsession/src/main/res/values/strings.xml @@ -15,12 +15,22 @@ <string name="MessageRecord_s_called_you">%s called you</string> <string name="MessageRecord_called_s">Called %s</string> <string name="MessageRecord_missed_call_from">Missed call from %s</string> - <string name="MessageRecord_you_disabled_disappearing_messages">You disabled disappearing messages.</string> - <string name="MessageRecord_s_disabled_disappearing_messages">%1$s disabled disappearing messages.</string> - <string name="MessageRecord_you_set_disappearing_message_time_to_s">You set the disappearing message timer to %1$s</string> - <string name="MessageRecord_s_set_disappearing_message_time_to_s">%1$s set the disappearing message timer to %2$s</string> + <string name="MessageRecord_follow_setting">Follow Setting</string> + <string name="AccessibilityId_follow_setting">Follow setting</string> + <string name="MessageRecord_you_turned_off_disappearing_messages">You have turned off disappearing messages.</string> + <string name="MessageRecord_you_turned_off_disappearing_messages_1_on_1">You turned off disappearing messages. Messages you send will no longer disappear.</string> + <string name="MessageRecord_s_turned_off_disappearing_messages">%1$s turned off disappearing messages.</string> + <string name="MessageRecord_s_turned_off_disappearing_messages_1_on_1">%1$s has turned off disappearing messages. Messages they send will no longer disappear.</string> + <string name="MessageRecord_you_set_messages_to_disappear_s_after_s">You have set messages to disappear %1$s after they have been %2$s</string> + <string name="MessageRecord_you_set_messages_to_disappear_s_after_s_1_on_1">You set your messages to disappear %1$s after they have been %2$s.</string> + <string name="MessageRecord_you_changed_messages_to_disappear_s_after_s">You have changed messages to disappear %1$s after they have been %2$s</string> + <string name="MessageRecord_s_set_messages_to_disappear_s_after_s">%1$s has set messages to disappear %2$s after they have been %3$s</string> + <string name="MessageRecord_s_set_messages_to_disappear_s_after_s_1_on_1">%1$s has set their messages to disappear %2$s after they have been %3$s.</string> + <string name="MessageRecord_s_changed_messages_to_disappear_s_after_s">%1$s has changed messages to disappear %2$s after they have been %3$s</string> <string name="MessageRecord_s_took_a_screenshot">%1$s took a screenshot.</string> <string name="MessageRecord_media_saved_by_s">Media saved by %1$s.</string> + <string name="MessageRecord_state_read">read</string> + <string name="MessageRecord_state_sent">sent</string> <!-- expiration --> <string name="expiration_off">Off</string> diff --git a/libsession/src/test/java/org/session/libsession/utilities/BencoderTest.kt b/libsession/src/test/java/org/session/libsession/utilities/BencoderTest.kt new file mode 100644 index 0000000000..d96fa6658f --- /dev/null +++ b/libsession/src/test/java/org/session/libsession/utilities/BencoderTest.kt @@ -0,0 +1,107 @@ +package org.session.libsession.utilities + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import org.session.libsession.utilities.bencode.Bencode +import org.session.libsession.utilities.bencode.BencodeDict +import org.session.libsession.utilities.bencode.BencodeInteger +import org.session.libsession.utilities.bencode.BencodeList +import org.session.libsession.utilities.bencode.bencode + +class BencoderTest { + + @Test + fun `it should decode a basic string`() { + val basicString = "5:howdy".toByteArray() + val bencoder = Bencode.Decoder(basicString) + val result = bencoder.decode() + assertEquals("howdy".bencode(), result) + } + + @Test + fun `it should decode a basic integer`() { + val basicInteger = "i3e".toByteArray() + val bencoder = Bencode.Decoder(basicInteger) + val result = bencoder.decode() + assertEquals(BencodeInteger(3), result) + } + + @Test + fun `it should decode a list of integers`() { + val basicIntList = "li1ei2ee".toByteArray() + val bencoder = Bencode.Decoder(basicIntList) + val result = bencoder.decode() + assertEquals( + BencodeList( + 1.bencode(), + 2.bencode() + ), + result + ) + } + + @Test + fun `it should decode a basic dict`() { + val basicDict = "d4:spaml1:a1:bee".toByteArray() + val bencoder = Bencode.Decoder(basicDict) + val result = bencoder.decode() + assertEquals( + BencodeDict( + "spam" to BencodeList( + "a".bencode(), + "b".bencode() + ) + ), + result + ) + } + + @Test + fun `it should encode a basic string`() { + val basicString = "5:howdy".toByteArray() + val element = "howdy".bencode() + assertArrayEquals(basicString, element.encode()) + } + + @Test + fun `it should encode a basic int`() { + val basicInt = "i3e".toByteArray() + val element = 3.bencode() + assertArrayEquals(basicInt, element.encode()) + } + + @Test + fun `it should encode a basic list`() { + val basicList = "li1ei2ee".toByteArray() + val element = BencodeList(1.bencode(),2.bencode()) + assertArrayEquals(basicList, element.encode()) + } + + @Test + fun `it should encode a basic dict`() { + val basicDict = "d4:spaml1:a1:bee".toByteArray() + val element = BencodeDict( + "spam" to BencodeList( + "a".bencode(), + "b".bencode() + ) + ) + assertArrayEquals(basicDict, element.encode()) + } + + @Test + fun `it should encode a more complex real world case`() { + val source = "d15:lastReadMessaged66:031122334455667788990011223344556677889900112233445566778899001122i1234568790e66:051122334455667788990011223344556677889900112233445566778899001122i1234568790ee5:seqNoi1ee".toByteArray() + val result = Bencode.Decoder(source).decode() + val expected = BencodeDict( + "lastReadMessage" to BencodeDict( + "051122334455667788990011223344556677889900112233445566778899001122" to 1234568790.bencode(), + "031122334455667788990011223344556677889900112233445566778899001122" to 1234568790.bencode() + ), + "seqNo" to BencodeInteger(1) + ) + assertEquals(expected, result) + } + +} \ No newline at end of file diff --git a/libsignal/build.gradle b/libsignal/build.gradle index 1ea5f2de04..5ac0d0b4fc 100644 --- a/libsignal/build.gradle +++ b/libsignal/build.gradle @@ -25,6 +25,6 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" testImplementation "junit:junit:$junitVersion" - testImplementation "org.assertj:assertj-core:1.7.1" + testImplementation "org.assertj:assertj-core:3.11.1" testImplementation "org.conscrypt:conscrypt-openjdk-uber:2.0.0" } diff --git a/libsignal/protobuf/SignalService.proto b/libsignal/protobuf/SignalService.proto index 68dd35ce61..6e13be72ef 100644 --- a/libsignal/protobuf/SignalService.proto +++ b/libsignal/protobuf/SignalService.proto @@ -43,6 +43,12 @@ message UnsendRequest { } message Content { + enum ExpirationType { + UNKNOWN = 0; + DELETE_AFTER_READ = 1; + DELETE_AFTER_SEND = 2; + } + optional DataMessage dataMessage = 1; optional CallMessage callMessage = 3; optional ReceiptMessage receiptMessage = 5; @@ -52,6 +58,9 @@ message Content { optional UnsendRequest unsendRequest = 9; optional MessageRequestResponse messageRequestResponse = 10; optional SharedConfigMessage sharedConfigMessage = 11; + optional ExpirationType expirationType = 12; + optional uint32 expirationTimer = 13; + optional uint64 lastDisappearingMessageChangeTimestamp = 14; } message KeyPair { @@ -163,20 +172,21 @@ message DataMessage { required Action action = 4; } - optional string body = 1; - repeated AttachmentPointer attachments = 2; - optional GroupContext group = 3; - optional uint32 flags = 4; - optional uint32 expireTimer = 5; - optional bytes profileKey = 6; - optional uint64 timestamp = 7; - optional Quote quote = 8; - repeated Preview preview = 10; - optional Reaction reaction = 11; - optional LokiProfile profile = 101; - optional OpenGroupInvitation openGroupInvitation = 102; - optional ClosedGroupControlMessage closedGroupControlMessage = 104; - optional string syncTarget = 105; + optional string body = 1; + repeated AttachmentPointer attachments = 2; + optional GroupContext group = 3; + optional uint32 flags = 4; + optional uint32 expireTimer = 5; + optional bytes profileKey = 6; + optional uint64 timestamp = 7; + optional Quote quote = 8; + repeated Preview preview = 10; + optional Reaction reaction = 11; + optional LokiProfile profile = 101; + optional OpenGroupInvitation openGroupInvitation = 102; + optional ClosedGroupControlMessage closedGroupControlMessage = 104; + optional string syncTarget = 105; + optional bool blocksCommunityMessageRequests = 106; } message CallMessage { diff --git a/libsignal/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt b/libsignal/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt index 18880f5538..37c00a037d 100644 --- a/libsignal/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt +++ b/libsignal/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt @@ -38,4 +38,7 @@ interface LokiAPIDatabaseProtocol { fun getForkInfo(): ForkInfo fun setForkInfo(forkInfo: ForkInfo) fun migrateLegacyOpenGroup(legacyServerId: String, newServerId: String) + fun getLastLegacySenderAddress(threadRecipientAddress: String): String? + fun setLastLegacySenderAddress(threadRecipientAddress: String, senderRecipientAddress: String?) + } diff --git a/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java b/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java index 8e26b05d92..b53af78ea3 100644 --- a/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java +++ b/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java @@ -2482,6 +2482,36 @@ public final class SignalServiceProtos { * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> */ org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder getSharedConfigMessageOrBuilder(); + + // optional .signalservice.Content.ExpirationType expirationType = 12; + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + boolean hasExpirationType(); + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType getExpirationType(); + + // optional uint32 expirationTimer = 13; + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + boolean hasExpirationTimer(); + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + int getExpirationTimer(); + + // optional uint64 lastDisappearingMessageChangeTimestamp = 14; + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + boolean hasLastDisappearingMessageChangeTimestamp(); + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + long getLastDisappearingMessageChangeTimestamp(); } /** * Protobuf type {@code signalservice.Content} @@ -2651,6 +2681,27 @@ public final class SignalServiceProtos { bitField0_ |= 0x00000100; break; } + case 96: { + int rawValue = input.readEnum(); + org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType value = org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType.valueOf(rawValue); + if (value == null) { + unknownFields.mergeVarintField(12, rawValue); + } else { + bitField0_ |= 0x00000200; + expirationType_ = value; + } + break; + } + case 104: { + bitField0_ |= 0x00000400; + expirationTimer_ = input.readUInt32(); + break; + } + case 112: { + bitField0_ |= 0x00000800; + lastDisappearingMessageChangeTimestamp_ = input.readUInt64(); + break; + } } } } catch (com.google.protobuf.InvalidProtocolBufferException e) { @@ -2690,6 +2741,97 @@ public final class SignalServiceProtos { return PARSER; } + /** + * Protobuf enum {@code signalservice.Content.ExpirationType} + */ + public enum ExpirationType + implements com.google.protobuf.ProtocolMessageEnum { + /** + * <code>UNKNOWN = 0;</code> + */ + UNKNOWN(0, 0), + /** + * <code>DELETE_AFTER_READ = 1;</code> + */ + DELETE_AFTER_READ(1, 1), + /** + * <code>DELETE_AFTER_SEND = 2;</code> + */ + DELETE_AFTER_SEND(2, 2), + ; + + /** + * <code>UNKNOWN = 0;</code> + */ + public static final int UNKNOWN_VALUE = 0; + /** + * <code>DELETE_AFTER_READ = 1;</code> + */ + public static final int DELETE_AFTER_READ_VALUE = 1; + /** + * <code>DELETE_AFTER_SEND = 2;</code> + */ + public static final int DELETE_AFTER_SEND_VALUE = 2; + + + public final int getNumber() { return value; } + + public static ExpirationType valueOf(int value) { + switch (value) { + case 0: return UNKNOWN; + case 1: return DELETE_AFTER_READ; + case 2: return DELETE_AFTER_SEND; + default: return null; + } + } + + public static com.google.protobuf.Internal.EnumLiteMap<ExpirationType> + internalGetValueMap() { + return internalValueMap; + } + private static com.google.protobuf.Internal.EnumLiteMap<ExpirationType> + internalValueMap = + new com.google.protobuf.Internal.EnumLiteMap<ExpirationType>() { + public ExpirationType findValueByNumber(int number) { + return ExpirationType.valueOf(number); + } + }; + + public final com.google.protobuf.Descriptors.EnumValueDescriptor + getValueDescriptor() { + return getDescriptor().getValues().get(index); + } + public final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptorForType() { + return getDescriptor(); + } + public static final com.google.protobuf.Descriptors.EnumDescriptor + getDescriptor() { + return org.session.libsignal.protos.SignalServiceProtos.Content.getDescriptor().getEnumTypes().get(0); + } + + private static final ExpirationType[] VALUES = values(); + + public static ExpirationType valueOf( + com.google.protobuf.Descriptors.EnumValueDescriptor desc) { + if (desc.getType() != getDescriptor()) { + throw new java.lang.IllegalArgumentException( + "EnumValueDescriptor is not for this type."); + } + return VALUES[desc.getIndex()]; + } + + private final int index; + private final int value; + + private ExpirationType(int index, int value) { + this.index = index; + this.value = value; + } + + // @@protoc_insertion_point(enum_scope:signalservice.Content.ExpirationType) + } + private int bitField0_; // optional .signalservice.DataMessage dataMessage = 1; public static final int DATAMESSAGE_FIELD_NUMBER = 1; @@ -2889,6 +3031,54 @@ public final class SignalServiceProtos { return sharedConfigMessage_; } + // optional .signalservice.Content.ExpirationType expirationType = 12; + public static final int EXPIRATIONTYPE_FIELD_NUMBER = 12; + private org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType expirationType_; + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + public boolean hasExpirationType() { + return ((bitField0_ & 0x00000200) == 0x00000200); + } + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + public org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType getExpirationType() { + return expirationType_; + } + + // optional uint32 expirationTimer = 13; + public static final int EXPIRATIONTIMER_FIELD_NUMBER = 13; + private int expirationTimer_; + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + public boolean hasExpirationTimer() { + return ((bitField0_ & 0x00000400) == 0x00000400); + } + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + public int getExpirationTimer() { + return expirationTimer_; + } + + // optional uint64 lastDisappearingMessageChangeTimestamp = 14; + public static final int LASTDISAPPEARINGMESSAGECHANGETIMESTAMP_FIELD_NUMBER = 14; + private long lastDisappearingMessageChangeTimestamp_; + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + public boolean hasLastDisappearingMessageChangeTimestamp() { + return ((bitField0_ & 0x00000800) == 0x00000800); + } + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + public long getLastDisappearingMessageChangeTimestamp() { + return lastDisappearingMessageChangeTimestamp_; + } + private void initFields() { dataMessage_ = org.session.libsignal.protos.SignalServiceProtos.DataMessage.getDefaultInstance(); callMessage_ = org.session.libsignal.protos.SignalServiceProtos.CallMessage.getDefaultInstance(); @@ -2899,6 +3089,9 @@ public final class SignalServiceProtos { unsendRequest_ = org.session.libsignal.protos.SignalServiceProtos.UnsendRequest.getDefaultInstance(); messageRequestResponse_ = org.session.libsignal.protos.SignalServiceProtos.MessageRequestResponse.getDefaultInstance(); sharedConfigMessage_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); + expirationType_ = org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType.UNKNOWN; + expirationTimer_ = 0; + lastDisappearingMessageChangeTimestamp_ = 0L; } private byte memoizedIsInitialized = -1; public final boolean isInitialized() { @@ -2993,6 +3186,15 @@ public final class SignalServiceProtos { if (((bitField0_ & 0x00000100) == 0x00000100)) { output.writeMessage(11, sharedConfigMessage_); } + if (((bitField0_ & 0x00000200) == 0x00000200)) { + output.writeEnum(12, expirationType_.getNumber()); + } + if (((bitField0_ & 0x00000400) == 0x00000400)) { + output.writeUInt32(13, expirationTimer_); + } + if (((bitField0_ & 0x00000800) == 0x00000800)) { + output.writeUInt64(14, lastDisappearingMessageChangeTimestamp_); + } getUnknownFields().writeTo(output); } @@ -3038,6 +3240,18 @@ public final class SignalServiceProtos { size += com.google.protobuf.CodedOutputStream .computeMessageSize(11, sharedConfigMessage_); } + if (((bitField0_ & 0x00000200) == 0x00000200)) { + size += com.google.protobuf.CodedOutputStream + .computeEnumSize(12, expirationType_.getNumber()); + } + if (((bitField0_ & 0x00000400) == 0x00000400)) { + size += com.google.protobuf.CodedOutputStream + .computeUInt32Size(13, expirationTimer_); + } + if (((bitField0_ & 0x00000800) == 0x00000800)) { + size += com.google.protobuf.CodedOutputStream + .computeUInt64Size(14, lastDisappearingMessageChangeTimestamp_); + } size += getUnknownFields().getSerializedSize(); memoizedSerializedSize = size; return size; @@ -3217,6 +3431,12 @@ public final class SignalServiceProtos { sharedConfigMessageBuilder_.clear(); } bitField0_ = (bitField0_ & ~0x00000100); + expirationType_ = org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType.UNKNOWN; + bitField0_ = (bitField0_ & ~0x00000200); + expirationTimer_ = 0; + bitField0_ = (bitField0_ & ~0x00000400); + lastDisappearingMessageChangeTimestamp_ = 0L; + bitField0_ = (bitField0_ & ~0x00000800); return this; } @@ -3317,6 +3537,18 @@ public final class SignalServiceProtos { } else { result.sharedConfigMessage_ = sharedConfigMessageBuilder_.build(); } + if (((from_bitField0_ & 0x00000200) == 0x00000200)) { + to_bitField0_ |= 0x00000200; + } + result.expirationType_ = expirationType_; + if (((from_bitField0_ & 0x00000400) == 0x00000400)) { + to_bitField0_ |= 0x00000400; + } + result.expirationTimer_ = expirationTimer_; + if (((from_bitField0_ & 0x00000800) == 0x00000800)) { + to_bitField0_ |= 0x00000800; + } + result.lastDisappearingMessageChangeTimestamp_ = lastDisappearingMessageChangeTimestamp_; result.bitField0_ = to_bitField0_; onBuilt(); return result; @@ -3360,6 +3592,15 @@ public final class SignalServiceProtos { if (other.hasSharedConfigMessage()) { mergeSharedConfigMessage(other.getSharedConfigMessage()); } + if (other.hasExpirationType()) { + setExpirationType(other.getExpirationType()); + } + if (other.hasExpirationTimer()) { + setExpirationTimer(other.getExpirationTimer()); + } + if (other.hasLastDisappearingMessageChangeTimestamp()) { + setLastDisappearingMessageChangeTimestamp(other.getLastDisappearingMessageChangeTimestamp()); + } this.mergeUnknownFields(other.getUnknownFields()); return this; } @@ -4494,6 +4735,108 @@ public final class SignalServiceProtos { return sharedConfigMessageBuilder_; } + // optional .signalservice.Content.ExpirationType expirationType = 12; + private org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType expirationType_ = org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType.UNKNOWN; + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + public boolean hasExpirationType() { + return ((bitField0_ & 0x00000200) == 0x00000200); + } + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + public org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType getExpirationType() { + return expirationType_; + } + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + public Builder setExpirationType(org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000200; + expirationType_ = value; + onChanged(); + return this; + } + /** + * <code>optional .signalservice.Content.ExpirationType expirationType = 12;</code> + */ + public Builder clearExpirationType() { + bitField0_ = (bitField0_ & ~0x00000200); + expirationType_ = org.session.libsignal.protos.SignalServiceProtos.Content.ExpirationType.UNKNOWN; + onChanged(); + return this; + } + + // optional uint32 expirationTimer = 13; + private int expirationTimer_ ; + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + public boolean hasExpirationTimer() { + return ((bitField0_ & 0x00000400) == 0x00000400); + } + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + public int getExpirationTimer() { + return expirationTimer_; + } + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + public Builder setExpirationTimer(int value) { + bitField0_ |= 0x00000400; + expirationTimer_ = value; + onChanged(); + return this; + } + /** + * <code>optional uint32 expirationTimer = 13;</code> + */ + public Builder clearExpirationTimer() { + bitField0_ = (bitField0_ & ~0x00000400); + expirationTimer_ = 0; + onChanged(); + return this; + } + + // optional uint64 lastDisappearingMessageChangeTimestamp = 14; + private long lastDisappearingMessageChangeTimestamp_ ; + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + public boolean hasLastDisappearingMessageChangeTimestamp() { + return ((bitField0_ & 0x00000800) == 0x00000800); + } + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + public long getLastDisappearingMessageChangeTimestamp() { + return lastDisappearingMessageChangeTimestamp_; + } + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + public Builder setLastDisappearingMessageChangeTimestamp(long value) { + bitField0_ |= 0x00000800; + lastDisappearingMessageChangeTimestamp_ = value; + onChanged(); + return this; + } + /** + * <code>optional uint64 lastDisappearingMessageChangeTimestamp = 14;</code> + */ + public Builder clearLastDisappearingMessageChangeTimestamp() { + bitField0_ = (bitField0_ & ~0x00000800); + lastDisappearingMessageChangeTimestamp_ = 0L; + onChanged(); + return this; + } + // @@protoc_insertion_point(builder_scope:signalservice.Content) } @@ -5890,6 +6233,16 @@ public final class SignalServiceProtos { */ com.google.protobuf.ByteString getSyncTargetBytes(); + + // optional bool blocksCommunityMessageRequests = 106; + /** + * <code>optional bool blocksCommunityMessageRequests = 106;</code> + */ + boolean hasBlocksCommunityMessageRequests(); + /** + * <code>optional bool blocksCommunityMessageRequests = 106;</code> + */ + boolean getBlocksCommunityMessageRequests(); } /** * Protobuf type {@code signalservice.DataMessage} @@ -6066,6 +6419,11 @@ public final class SignalServiceProtos { syncTarget_ = input.readBytes(); break; } + case 848: { + bitField0_ |= 0x00001000; + blocksCommunityMessageRequests_ = input.readBool(); + break; + } } } } catch (com.google.protobuf.InvalidProtocolBufferException e) { @@ -14336,6 +14694,22 @@ public final class SignalServiceProtos { } } + // optional bool blocksCommunityMessageRequests = 106; + public static final int BLOCKSCOMMUNITYMESSAGEREQUESTS_FIELD_NUMBER = 106; + private boolean blocksCommunityMessageRequests_; + /** + * <code>optional bool blocksCommunityMessageRequests = 106;</code> + */ + public boolean hasBlocksCommunityMessageRequests() { + return ((bitField0_ & 0x00001000) == 0x00001000); + } + /** + * <code>optional bool blocksCommunityMessageRequests = 106;</code> + */ + public boolean getBlocksCommunityMessageRequests() { + return blocksCommunityMessageRequests_; + } + private void initFields() { body_ = ""; attachments_ = java.util.Collections.emptyList(); @@ -14351,6 +14725,7 @@ public final class SignalServiceProtos { openGroupInvitation_ = org.session.libsignal.protos.SignalServiceProtos.DataMessage.OpenGroupInvitation.getDefaultInstance(); closedGroupControlMessage_ = org.session.libsignal.protos.SignalServiceProtos.DataMessage.ClosedGroupControlMessage.getDefaultInstance(); syncTarget_ = ""; + blocksCommunityMessageRequests_ = false; } private byte memoizedIsInitialized = -1; public final boolean isInitialized() { @@ -14448,6 +14823,9 @@ public final class SignalServiceProtos { if (((bitField0_ & 0x00000800) == 0x00000800)) { output.writeBytes(105, getSyncTargetBytes()); } + if (((bitField0_ & 0x00001000) == 0x00001000)) { + output.writeBool(106, blocksCommunityMessageRequests_); + } getUnknownFields().writeTo(output); } @@ -14513,6 +14891,10 @@ public final class SignalServiceProtos { size += com.google.protobuf.CodedOutputStream .computeBytesSize(105, getSyncTargetBytes()); } + if (((bitField0_ & 0x00001000) == 0x00001000)) { + size += com.google.protobuf.CodedOutputStream + .computeBoolSize(106, blocksCommunityMessageRequests_); + } size += getUnknownFields().getSerializedSize(); memoizedSerializedSize = size; return size; @@ -14697,6 +15079,8 @@ public final class SignalServiceProtos { bitField0_ = (bitField0_ & ~0x00001000); syncTarget_ = ""; bitField0_ = (bitField0_ & ~0x00002000); + blocksCommunityMessageRequests_ = false; + bitField0_ = (bitField0_ & ~0x00004000); return this; } @@ -14815,6 +15199,10 @@ public final class SignalServiceProtos { to_bitField0_ |= 0x00000800; } result.syncTarget_ = syncTarget_; + if (((from_bitField0_ & 0x00004000) == 0x00004000)) { + to_bitField0_ |= 0x00001000; + } + result.blocksCommunityMessageRequests_ = blocksCommunityMessageRequests_; result.bitField0_ = to_bitField0_; onBuilt(); return result; @@ -14923,6 +15311,9 @@ public final class SignalServiceProtos { syncTarget_ = other.syncTarget_; onChanged(); } + if (other.hasBlocksCommunityMessageRequests()) { + setBlocksCommunityMessageRequests(other.getBlocksCommunityMessageRequests()); + } this.mergeUnknownFields(other.getUnknownFields()); return this; } @@ -16457,6 +16848,39 @@ public final class SignalServiceProtos { return this; } + // optional bool blocksCommunityMessageRequests = 106; + private boolean blocksCommunityMessageRequests_ ; + /** + * <code>optional bool blocksCommunityMessageRequests = 106;</code> + */ + public boolean hasBlocksCommunityMessageRequests() { + return ((bitField0_ & 0x00004000) == 0x00004000); + } + /** + * <code>optional bool blocksCommunityMessageRequests = 106;</code> + */ + public boolean getBlocksCommunityMessageRequests() { + return blocksCommunityMessageRequests_; + } + /** + * <code>optional bool blocksCommunityMessageRequests = 106;</code> + */ + public Builder setBlocksCommunityMessageRequests(boolean value) { + bitField0_ |= 0x00004000; + blocksCommunityMessageRequests_ = value; + onChanged(); + return this; + } + /** + * <code>optional bool blocksCommunityMessageRequests = 106;</code> + */ + public Builder clearBlocksCommunityMessageRequests() { + bitField0_ = (bitField0_ & ~0x00004000); + blocksCommunityMessageRequests_ = false; + onChanged(); + return this; + } + // @@protoc_insertion_point(builder_scope:signalservice.DataMessage) } @@ -27141,7 +27565,7 @@ public final class SignalServiceProtos { "\002(\004\0223\n\006action\030\002 \002(\0162#.signalservice.Typi" + "ngMessage.Action\"\"\n\006Action\022\013\n\007STARTED\020\000\022" + "\013\n\007STOPPED\020\001\"2\n\rUnsendRequest\022\021\n\ttimesta", - "mp\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\"\246\004\n\007Content\022/\n\013" + + "mp\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\"\373\005\n\007Content\022/\n\013" + "dataMessage\030\001 \001(\0132\032.signalservice.DataMe" + "ssage\022/\n\013callMessage\030\003 \001(\0132\032.signalservi" + "ce.CallMessage\0225\n\016receiptMessage\030\005 \001(\0132\035" + @@ -27155,102 +27579,108 @@ public final class SignalServiceProtos { "t\022E\n\026messageRequestResponse\030\n \001(\0132%.sign" + "alservice.MessageRequestResponse\022?\n\023shar" + "edConfigMessage\030\013 \001(\0132\".signalservice.Sh" + - "aredConfigMessage\"0\n\007KeyPair\022\021\n\tpublicKe" + - "y\030\001 \002(\014\022\022\n\nprivateKey\030\002 \002(\014\"\226\001\n\032DataExtr" + - "actionNotification\022<\n\004type\030\001 \002(\0162..signa" + - "lservice.DataExtractionNotification.Type" + - "\022\021\n\ttimestamp\030\002 \001(\004\"\'\n\004Type\022\016\n\nSCREENSHO" + - "T\020\001\022\017\n\013MEDIA_SAVED\020\002\"\361\r\n\013DataMessage\022\014\n\004", - "body\030\001 \001(\t\0225\n\013attachments\030\002 \003(\0132 .signal" + - "service.AttachmentPointer\022*\n\005group\030\003 \001(\013" + - "2\033.signalservice.GroupContext\022\r\n\005flags\030\004" + - " \001(\r\022\023\n\013expireTimer\030\005 \001(\r\022\022\n\nprofileKey\030" + - "\006 \001(\014\022\021\n\ttimestamp\030\007 \001(\004\022/\n\005quote\030\010 \001(\0132" + - " .signalservice.DataMessage.Quote\0223\n\007pre" + - "view\030\n \003(\0132\".signalservice.DataMessage.P" + - "review\0225\n\010reaction\030\013 \001(\0132#.signalservice" + - ".DataMessage.Reaction\0227\n\007profile\030e \001(\0132&" + - ".signalservice.DataMessage.LokiProfile\022K", - "\n\023openGroupInvitation\030f \001(\0132..signalserv" + - "ice.DataMessage.OpenGroupInvitation\022W\n\031c" + - "losedGroupControlMessage\030h \001(\01324.signals" + - "ervice.DataMessage.ClosedGroupControlMes" + - "sage\022\022\n\nsyncTarget\030i \001(\t\032\225\002\n\005Quote\022\n\n\002id" + - "\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\022\014\n\004text\030\003 \001(\t\022F\n\013" + - "attachments\030\004 \003(\01321.signalservice.DataMe" + - "ssage.Quote.QuotedAttachment\032\231\001\n\020QuotedA" + - "ttachment\022\023\n\013contentType\030\001 \001(\t\022\020\n\010fileNa" + - "me\030\002 \001(\t\0223\n\tthumbnail\030\003 \001(\0132 .signalserv", - "ice.AttachmentPointer\022\r\n\005flags\030\004 \001(\r\"\032\n\005" + - "Flags\022\021\n\rVOICE_MESSAGE\020\001\032V\n\007Preview\022\013\n\003u" + - "rl\030\001 \002(\t\022\r\n\005title\030\002 \001(\t\022/\n\005image\030\003 \001(\0132 " + - ".signalservice.AttachmentPointer\032:\n\013Loki" + - "Profile\022\023\n\013displayName\030\001 \001(\t\022\026\n\016profileP" + - "icture\030\002 \001(\t\0320\n\023OpenGroupInvitation\022\013\n\003u" + - "rl\030\001 \002(\t\022\014\n\004name\030\003 \002(\t\032\374\003\n\031ClosedGroupCo" + - "ntrolMessage\022G\n\004type\030\001 \002(\01629.signalservi" + - "ce.DataMessage.ClosedGroupControlMessage" + - ".Type\022\021\n\tpublicKey\030\002 \001(\014\022\014\n\004name\030\003 \001(\t\0221", - "\n\021encryptionKeyPair\030\004 \001(\0132\026.signalservic" + - "e.KeyPair\022\017\n\007members\030\005 \003(\014\022\016\n\006admins\030\006 \003" + - "(\014\022U\n\010wrappers\030\007 \003(\0132C.signalservice.Dat" + - "aMessage.ClosedGroupControlMessage.KeyPa" + - "irWrapper\022\027\n\017expirationTimer\030\010 \001(\r\032=\n\016Ke" + - "yPairWrapper\022\021\n\tpublicKey\030\001 \002(\014\022\030\n\020encry" + - "ptedKeyPair\030\002 \002(\014\"r\n\004Type\022\007\n\003NEW\020\001\022\027\n\023EN" + - "CRYPTION_KEY_PAIR\020\003\022\017\n\013NAME_CHANGE\020\004\022\021\n\r" + - "MEMBERS_ADDED\020\005\022\023\n\017MEMBERS_REMOVED\020\006\022\017\n\013" + - "MEMBER_LEFT\020\007\032\222\001\n\010Reaction\022\n\n\002id\030\001 \002(\004\022\016", - "\n\006author\030\002 \002(\t\022\r\n\005emoji\030\003 \001(\t\022:\n\006action\030" + - "\004 \002(\0162*.signalservice.DataMessage.Reacti" + - "on.Action\"\037\n\006Action\022\t\n\005REACT\020\000\022\n\n\006REMOVE" + - "\020\001\"$\n\005Flags\022\033\n\027EXPIRATION_TIMER_UPDATE\020\002" + - "\"\352\001\n\013CallMessage\022-\n\004type\030\001 \002(\0162\037.signals" + - "ervice.CallMessage.Type\022\014\n\004sdps\030\002 \003(\t\022\027\n" + - "\017sdpMLineIndexes\030\003 \003(\r\022\017\n\007sdpMids\030\004 \003(\t\022" + - "\014\n\004uuid\030\005 \002(\t\"f\n\004Type\022\r\n\tPRE_OFFER\020\006\022\t\n\005" + - "OFFER\020\001\022\n\n\006ANSWER\020\002\022\026\n\022PROVISIONAL_ANSWE" + - "R\020\003\022\022\n\016ICE_CANDIDATES\020\004\022\014\n\010END_CALL\020\005\"\245\004", - "\n\024ConfigurationMessage\022E\n\014closedGroups\030\001" + - " \003(\0132/.signalservice.ConfigurationMessag" + - "e.ClosedGroup\022\022\n\nopenGroups\030\002 \003(\t\022\023\n\013dis" + - "playName\030\003 \001(\t\022\026\n\016profilePicture\030\004 \001(\t\022\022" + - "\n\nprofileKey\030\005 \001(\014\022=\n\010contacts\030\006 \003(\0132+.s" + - "ignalservice.ConfigurationMessage.Contac" + - "t\032\233\001\n\013ClosedGroup\022\021\n\tpublicKey\030\001 \001(\014\022\014\n\004" + - "name\030\002 \001(\t\0221\n\021encryptionKeyPair\030\003 \001(\0132\026." + - "signalservice.KeyPair\022\017\n\007members\030\004 \003(\014\022\016" + - "\n\006admins\030\005 \003(\014\022\027\n\017expirationTimer\030\006 \001(\r\032", - "\223\001\n\007Contact\022\021\n\tpublicKey\030\001 \002(\014\022\014\n\004name\030\002" + - " \002(\t\022\026\n\016profilePicture\030\003 \001(\t\022\022\n\nprofileK" + - "ey\030\004 \001(\014\022\022\n\nisApproved\030\005 \001(\010\022\021\n\tisBlocke" + - "d\030\006 \001(\010\022\024\n\014didApproveMe\030\007 \001(\010\"y\n\026Message" + - "RequestResponse\022\022\n\nisApproved\030\001 \002(\010\022\022\n\np" + - "rofileKey\030\002 \001(\014\0227\n\007profile\030\003 \001(\0132&.signa" + - "lservice.DataMessage.LokiProfile\"\375\001\n\023Sha" + - "redConfigMessage\0225\n\004kind\030\001 \002(\0162\'.signals" + - "ervice.SharedConfigMessage.Kind\022\r\n\005seqno" + - "\030\002 \002(\003\022\014\n\004data\030\003 \002(\014\"\221\001\n\004Kind\022\020\n\014USER_PR", - "OFILE\020\001\022\014\n\010CONTACTS\020\002\022\027\n\023CONVO_INFO_VOLA" + - "TILE\020\003\022\n\n\006GROUPS\020\004\022\025\n\021CLOSED_GROUP_INFO\020" + - "\005\022\030\n\024CLOSED_GROUP_MEMBERS\020\006\022\023\n\017ENCRYPTIO" + - "N_KEYS\020\007\"u\n\016ReceiptMessage\0220\n\004type\030\001 \002(\016" + - "2\".signalservice.ReceiptMessage.Type\022\021\n\t" + - "timestamp\030\002 \003(\004\"\036\n\004Type\022\014\n\010DELIVERY\020\000\022\010\n" + - "\004READ\020\001\"\354\001\n\021AttachmentPointer\022\n\n\002id\030\001 \002(" + - "\006\022\023\n\013contentType\030\002 \001(\t\022\013\n\003key\030\003 \001(\014\022\014\n\004s" + - "ize\030\004 \001(\r\022\021\n\tthumbnail\030\005 \001(\014\022\016\n\006digest\030\006" + - " \001(\014\022\020\n\010fileName\030\007 \001(\t\022\r\n\005flags\030\010 \001(\r\022\r\n", - "\005width\030\t \001(\r\022\016\n\006height\030\n \001(\r\022\017\n\007caption\030" + - "\013 \001(\t\022\013\n\003url\030e \001(\t\"\032\n\005Flags\022\021\n\rVOICE_MES" + - "SAGE\020\001\"\365\001\n\014GroupContext\022\n\n\002id\030\001 \001(\014\022.\n\004t" + - "ype\030\002 \001(\0162 .signalservice.GroupContext.T" + - "ype\022\014\n\004name\030\003 \001(\t\022\017\n\007members\030\004 \003(\t\0220\n\006av" + - "atar\030\005 \001(\0132 .signalservice.AttachmentPoi" + - "nter\022\016\n\006admins\030\006 \003(\t\"H\n\004Type\022\013\n\007UNKNOWN\020" + - "\000\022\n\n\006UPDATE\020\001\022\013\n\007DELIVER\020\002\022\010\n\004QUIT\020\003\022\020\n\014" + - "REQUEST_INFO\020\004B3\n\034org.session.libsignal." + - "protosB\023SignalServiceProtos" + "aredConfigMessage\022=\n\016expirationType\030\014 \001(" + + "\0162%.signalservice.Content.ExpirationType" + + "\022\027\n\017expirationTimer\030\r \001(\r\022.\n&lastDisappe" + + "aringMessageChangeTimestamp\030\016 \001(\004\"K\n\016Exp" + + "irationType\022\013\n\007UNKNOWN\020\000\022\025\n\021DELETE_AFTER" + + "_READ\020\001\022\025\n\021DELETE_AFTER_SEND\020\002\"0\n\007KeyPai", + "r\022\021\n\tpublicKey\030\001 \002(\014\022\022\n\nprivateKey\030\002 \002(\014" + + "\"\226\001\n\032DataExtractionNotification\022<\n\004type\030" + + "\001 \002(\0162..signalservice.DataExtractionNoti" + + "fication.Type\022\021\n\ttimestamp\030\002 \001(\004\"\'\n\004Type" + + "\022\016\n\nSCREENSHOT\020\001\022\017\n\013MEDIA_SAVED\020\002\"\231\016\n\013Da" + + "taMessage\022\014\n\004body\030\001 \001(\t\0225\n\013attachments\030\002" + + " \003(\0132 .signalservice.AttachmentPointer\022*" + + "\n\005group\030\003 \001(\0132\033.signalservice.GroupConte" + + "xt\022\r\n\005flags\030\004 \001(\r\022\023\n\013expireTimer\030\005 \001(\r\022\022" + + "\n\nprofileKey\030\006 \001(\014\022\021\n\ttimestamp\030\007 \001(\004\022/\n", + "\005quote\030\010 \001(\0132 .signalservice.DataMessage" + + ".Quote\0223\n\007preview\030\n \003(\0132\".signalservice." + + "DataMessage.Preview\0225\n\010reaction\030\013 \001(\0132#." + + "signalservice.DataMessage.Reaction\0227\n\007pr" + + "ofile\030e \001(\0132&.signalservice.DataMessage." + + "LokiProfile\022K\n\023openGroupInvitation\030f \001(\013" + + "2..signalservice.DataMessage.OpenGroupIn" + + "vitation\022W\n\031closedGroupControlMessage\030h " + + "\001(\01324.signalservice.DataMessage.ClosedGr" + + "oupControlMessage\022\022\n\nsyncTarget\030i \001(\t\022&\n", + "\036blocksCommunityMessageRequests\030j \001(\010\032\225\002" + + "\n\005Quote\022\n\n\002id\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\022\014\n\004t" + + "ext\030\003 \001(\t\022F\n\013attachments\030\004 \003(\01321.signals" + + "ervice.DataMessage.Quote.QuotedAttachmen" + + "t\032\231\001\n\020QuotedAttachment\022\023\n\013contentType\030\001 " + + "\001(\t\022\020\n\010fileName\030\002 \001(\t\0223\n\tthumbnail\030\003 \001(\013" + + "2 .signalservice.AttachmentPointer\022\r\n\005fl" + + "ags\030\004 \001(\r\"\032\n\005Flags\022\021\n\rVOICE_MESSAGE\020\001\032V\n" + + "\007Preview\022\013\n\003url\030\001 \002(\t\022\r\n\005title\030\002 \001(\t\022/\n\005" + + "image\030\003 \001(\0132 .signalservice.AttachmentPo", + "inter\032:\n\013LokiProfile\022\023\n\013displayName\030\001 \001(" + + "\t\022\026\n\016profilePicture\030\002 \001(\t\0320\n\023OpenGroupIn" + + "vitation\022\013\n\003url\030\001 \002(\t\022\014\n\004name\030\003 \002(\t\032\374\003\n\031" + + "ClosedGroupControlMessage\022G\n\004type\030\001 \002(\0162" + + "9.signalservice.DataMessage.ClosedGroupC" + + "ontrolMessage.Type\022\021\n\tpublicKey\030\002 \001(\014\022\014\n" + + "\004name\030\003 \001(\t\0221\n\021encryptionKeyPair\030\004 \001(\0132\026" + + ".signalservice.KeyPair\022\017\n\007members\030\005 \003(\014\022" + + "\016\n\006admins\030\006 \003(\014\022U\n\010wrappers\030\007 \003(\0132C.sign" + + "alservice.DataMessage.ClosedGroupControl", + "Message.KeyPairWrapper\022\027\n\017expirationTime" + + "r\030\010 \001(\r\032=\n\016KeyPairWrapper\022\021\n\tpublicKey\030\001" + + " \002(\014\022\030\n\020encryptedKeyPair\030\002 \002(\014\"r\n\004Type\022\007" + + "\n\003NEW\020\001\022\027\n\023ENCRYPTION_KEY_PAIR\020\003\022\017\n\013NAME" + + "_CHANGE\020\004\022\021\n\rMEMBERS_ADDED\020\005\022\023\n\017MEMBERS_" + + "REMOVED\020\006\022\017\n\013MEMBER_LEFT\020\007\032\222\001\n\010Reaction\022" + + "\n\n\002id\030\001 \002(\004\022\016\n\006author\030\002 \002(\t\022\r\n\005emoji\030\003 \001" + + "(\t\022:\n\006action\030\004 \002(\0162*.signalservice.DataM" + + "essage.Reaction.Action\"\037\n\006Action\022\t\n\005REAC" + + "T\020\000\022\n\n\006REMOVE\020\001\"$\n\005Flags\022\033\n\027EXPIRATION_T", + "IMER_UPDATE\020\002\"\352\001\n\013CallMessage\022-\n\004type\030\001 " + + "\002(\0162\037.signalservice.CallMessage.Type\022\014\n\004" + + "sdps\030\002 \003(\t\022\027\n\017sdpMLineIndexes\030\003 \003(\r\022\017\n\007s" + + "dpMids\030\004 \003(\t\022\014\n\004uuid\030\005 \002(\t\"f\n\004Type\022\r\n\tPR" + + "E_OFFER\020\006\022\t\n\005OFFER\020\001\022\n\n\006ANSWER\020\002\022\026\n\022PROV" + + "ISIONAL_ANSWER\020\003\022\022\n\016ICE_CANDIDATES\020\004\022\014\n\010" + + "END_CALL\020\005\"\245\004\n\024ConfigurationMessage\022E\n\014c" + + "losedGroups\030\001 \003(\0132/.signalservice.Config" + + "urationMessage.ClosedGroup\022\022\n\nopenGroups" + + "\030\002 \003(\t\022\023\n\013displayName\030\003 \001(\t\022\026\n\016profilePi", + "cture\030\004 \001(\t\022\022\n\nprofileKey\030\005 \001(\014\022=\n\010conta" + + "cts\030\006 \003(\0132+.signalservice.ConfigurationM" + + "essage.Contact\032\233\001\n\013ClosedGroup\022\021\n\tpublic" + + "Key\030\001 \001(\014\022\014\n\004name\030\002 \001(\t\0221\n\021encryptionKey" + + "Pair\030\003 \001(\0132\026.signalservice.KeyPair\022\017\n\007me" + + "mbers\030\004 \003(\014\022\016\n\006admins\030\005 \003(\014\022\027\n\017expiratio" + + "nTimer\030\006 \001(\r\032\223\001\n\007Contact\022\021\n\tpublicKey\030\001 " + + "\002(\014\022\014\n\004name\030\002 \002(\t\022\026\n\016profilePicture\030\003 \001(" + + "\t\022\022\n\nprofileKey\030\004 \001(\014\022\022\n\nisApproved\030\005 \001(" + + "\010\022\021\n\tisBlocked\030\006 \001(\010\022\024\n\014didApproveMe\030\007 \001", + "(\010\"y\n\026MessageRequestResponse\022\022\n\nisApprov" + + "ed\030\001 \002(\010\022\022\n\nprofileKey\030\002 \001(\014\0227\n\007profile\030" + + "\003 \001(\0132&.signalservice.DataMessage.LokiPr" + + "ofile\"\375\001\n\023SharedConfigMessage\0225\n\004kind\030\001 " + + "\002(\0162\'.signalservice.SharedConfigMessage." + + "Kind\022\r\n\005seqno\030\002 \002(\003\022\014\n\004data\030\003 \002(\014\"\221\001\n\004Ki" + + "nd\022\020\n\014USER_PROFILE\020\001\022\014\n\010CONTACTS\020\002\022\027\n\023CO" + + "NVO_INFO_VOLATILE\020\003\022\n\n\006GROUPS\020\004\022\025\n\021CLOSE" + + "D_GROUP_INFO\020\005\022\030\n\024CLOSED_GROUP_MEMBERS\020\006" + + "\022\023\n\017ENCRYPTION_KEYS\020\007\"u\n\016ReceiptMessage\022", + "0\n\004type\030\001 \002(\0162\".signalservice.ReceiptMes" + + "sage.Type\022\021\n\ttimestamp\030\002 \003(\004\"\036\n\004Type\022\014\n\010" + + "DELIVERY\020\000\022\010\n\004READ\020\001\"\354\001\n\021AttachmentPoint" + + "er\022\n\n\002id\030\001 \002(\006\022\023\n\013contentType\030\002 \001(\t\022\013\n\003k" + + "ey\030\003 \001(\014\022\014\n\004size\030\004 \001(\r\022\021\n\tthumbnail\030\005 \001(" + + "\014\022\016\n\006digest\030\006 \001(\014\022\020\n\010fileName\030\007 \001(\t\022\r\n\005f" + + "lags\030\010 \001(\r\022\r\n\005width\030\t \001(\r\022\016\n\006height\030\n \001(" + + "\r\022\017\n\007caption\030\013 \001(\t\022\013\n\003url\030e \001(\t\"\032\n\005Flags" + + "\022\021\n\rVOICE_MESSAGE\020\001\"\365\001\n\014GroupContext\022\n\n\002" + + "id\030\001 \001(\014\022.\n\004type\030\002 \001(\0162 .signalservice.G", + "roupContext.Type\022\014\n\004name\030\003 \001(\t\022\017\n\007member" + + "s\030\004 \003(\t\0220\n\006avatar\030\005 \001(\0132 .signalservice." + + "AttachmentPointer\022\016\n\006admins\030\006 \003(\t\"H\n\004Typ" + + "e\022\013\n\007UNKNOWN\020\000\022\n\n\006UPDATE\020\001\022\013\n\007DELIVER\020\002\022" + + "\010\n\004QUIT\020\003\022\020\n\014REQUEST_INFO\020\004B3\n\034org.sessi" + + "on.libsignal.protosB\023SignalServiceProtos" }; com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner assigner = new com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner() { @@ -27280,7 +27710,7 @@ public final class SignalServiceProtos { internal_static_signalservice_Content_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_Content_descriptor, - new java.lang.String[] { "DataMessage", "CallMessage", "ReceiptMessage", "TypingMessage", "ConfigurationMessage", "DataExtractionNotification", "UnsendRequest", "MessageRequestResponse", "SharedConfigMessage", }); + new java.lang.String[] { "DataMessage", "CallMessage", "ReceiptMessage", "TypingMessage", "ConfigurationMessage", "DataExtractionNotification", "UnsendRequest", "MessageRequestResponse", "SharedConfigMessage", "ExpirationType", "ExpirationTimer", "LastDisappearingMessageChangeTimestamp", }); internal_static_signalservice_KeyPair_descriptor = getDescriptor().getMessageTypes().get(4); internal_static_signalservice_KeyPair_fieldAccessorTable = new @@ -27298,7 +27728,7 @@ public final class SignalServiceProtos { internal_static_signalservice_DataMessage_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_DataMessage_descriptor, - new java.lang.String[] { "Body", "Attachments", "Group", "Flags", "ExpireTimer", "ProfileKey", "Timestamp", "Quote", "Preview", "Reaction", "Profile", "OpenGroupInvitation", "ClosedGroupControlMessage", "SyncTarget", }); + new java.lang.String[] { "Body", "Attachments", "Group", "Flags", "ExpireTimer", "ProfileKey", "Timestamp", "Quote", "Preview", "Reaction", "Profile", "OpenGroupInvitation", "ClosedGroupControlMessage", "SyncTarget", "BlocksCommunityMessageRequests", }); internal_static_signalservice_DataMessage_Quote_descriptor = internal_static_signalservice_DataMessage_descriptor.getNestedTypes().get(0); internal_static_signalservice_DataMessage_Quote_fieldAccessorTable = new diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/ExternalStorageUtil.kt b/libsignal/src/main/java/org/session/libsignal/utilities/ExternalStorageUtil.kt index a8a8fd662f..f9ccb315cb 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/ExternalStorageUtil.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/ExternalStorageUtil.kt @@ -15,7 +15,7 @@ object ExternalStorageUtil { @Throws(NoExternalStorageException::class) fun getDir(context: Context, type: String?): File { return context.getExternalFilesDir(type) - ?: throw NoExternalStorageException("External storage dir is currently unavailable: $type") + ?: throw NoExternalStorageException("External storage dir is currently unavailable: $type") } @Throws(NoExternalStorageException::class) @@ -73,10 +73,7 @@ object ExternalStorageUtil { } @JvmStatic - fun getCleanFileName(fileName: String?): String? { - var fileName = fileName ?: return null - fileName = fileName.replace('\u202D', '\uFFFD') - fileName = fileName.replace('\u202E', '\uFFFD') - return fileName - } -} \ No newline at end of file + fun getCleanFileName(fileName: String?): String? = + fileName?.replace('\u202D', '\uFFFD') + ?.replace('\u202E', '\uFFFD') +} diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt b/libsignal/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt index d6361289c5..fdf8f107b9 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/PromiseUtilities.kt @@ -3,8 +3,13 @@ package org.session.libsignal.utilities import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred +import nl.komponents.kovenant.functional.map +import nl.komponents.kovenant.task import java.util.concurrent.TimeoutException +fun emptyPromise() = EMPTY_PROMISE +private val EMPTY_PROMISE: Promise<*, java.lang.Exception> = task {} + fun <V, E : Throwable> Promise<V, E>.get(defaultValue: V): V { return try { get() @@ -54,4 +59,11 @@ fun <V> Promise<V, Exception>.timeout(millis: Long): Promise<V, Exception> { if (!deferred.promise.isDone()) { deferred.reject(it) } } return deferred.promise -} \ No newline at end of file +} + +infix fun <V, E: Exception> Promise<V, E>.sideEffect( + callback: (value: V) -> Unit +) = map { + callback(it) + it +} diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt b/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt index ac81564bd1..6485babe80 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt @@ -1,23 +1,39 @@ package org.session.libsignal.utilities import android.os.Process -import java.util.concurrent.* +import kotlinx.coroutines.Dispatchers +import java.util.concurrent.ExecutorService +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.SynchronousQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import kotlin.coroutines.EmptyCoroutineContext object ThreadUtils { + const val TAG = "ThreadUtils" + const val PRIORITY_IMPORTANT_BACKGROUND_THREAD = Process.THREAD_PRIORITY_DEFAULT + Process.THREAD_PRIORITY_LESS_FAVORABLE - val executorPool: ExecutorService = Executors.newCachedThreadPool() + // Note: To see how many threads are running in our app at any given time we can use: + // val threadCount = getAllStackTraces().size @JvmStatic fun queue(target: Runnable) { - executorPool.execute(target) + queue(target::run) } fun queue(target: () -> Unit) { - executorPool.execute(target) + Dispatchers.IO.dispatch(EmptyCoroutineContext) { + try { + target() + } catch (e: Exception) { + Log.e(TAG, e) + } + } } + // Thread executor used by the audio recorder only @JvmStatic fun newDynamicSingleThreadedExecutor(): ExecutorService { val executor = ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, LinkedBlockingQueue()) diff --git a/scripts/drone-static-upload.sh b/scripts/drone-static-upload.sh new file mode 100755 index 0000000000..b5c9ee83f7 --- /dev/null +++ b/scripts/drone-static-upload.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +# Script used with Drone CI to upload build artifacts (because specifying all this in +# .drone.jsonnet is too painful). + +set -o errexit + +if [ -z "$SSH_KEY" ]; then + echo -e "\n\n\n\e[31;1mUnable to upload artifact: SSH_KEY not set\e[0m" + # Just warn but don't fail, so that this doesn't trigger a build failure for untrusted builds + exit 0 +fi + +echo "$SSH_KEY" >ssh_key + +set -o xtrace # Don't start tracing until *after* we write the ssh key + +chmod 600 ssh_key + +# Define the output paths +build_dir="app/build/outputs/apk/play/debug" +target_path="${build_dir}/$(ls ${build_dir} | grep -o 'session-[^[:space:]]*-universal.apk')" + +# Validate the paths exist +if [ ! -d $build_path ]; then + echo -e "\n\n\n\e[31;1mExpected a file to upload, found none\e[0m" >&2 + exit 1 +fi + +if [ -n "$DRONE_TAG" ]; then + # For a tag build use something like `session-android-v1.2.3-universal` + base="session-android-$DRONE_TAG-universal" +else + # Otherwise build a length name from the datetime and commit hash, such as: + # session-android-20200522T212342Z-04d7dcc54-universal + base="session-android-$(date --date=@$DRONE_BUILD_CREATED +%Y%m%dT%H%M%SZ)-${DRONE_COMMIT:0:9}-universal" +fi + +# Copy over the build products +mkdir -vp "$base" +cp -av $target_path "$base" + +# tar dat shiz up yo +archive="$base.tar.xz" +tar cJvf "$archive" "$base" + +upload_to="oxen.rocks/${DRONE_REPO// /_}/${DRONE_BRANCH// /_}" + +# sftp doesn't have any equivalent to mkdir -p, so we have to split the above up into a chain of +# -mkdir a/, -mkdir a/b/, -mkdir a/b/c/, ... commands. The leading `-` allows the command to fail +# without error. +upload_dirs=(${upload_to//\// }) +put_debug= +mkdirs= +dir_tmp="" +for p in "${upload_dirs[@]}"; do + dir_tmp="$dir_tmp$p/" + mkdirs="$mkdirs +-mkdir $dir_tmp" +done + +sftp -i ssh_key -b - -o StrictHostKeyChecking=off drone@oxen.rocks <<SFTP +$mkdirs +put $archive $upload_to +$put_debug +SFTP + +set +o xtrace + +echo -e "\n\n\n\n\e[32;1mUploaded to https://${upload_to}/${archive}\e[0m\n\n\n" diff --git a/scripts/drone-upload-exists.sh b/scripts/drone-upload-exists.sh new file mode 100755 index 0000000000..bc9b112c26 --- /dev/null +++ b/scripts/drone-upload-exists.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# +# Script used with Drone CI to check for the existence of a build artifact. + +if [[ -z ${DRONE_REPO} || -z ${DRONE_PULL_REQUEST} ]]; then + echo -e "\n\n\n\n\e[31;1mRequired env variables not specified, likely a tag build so just failing\e[0m\n\n\n" + exit 1 +fi + +# This file info MUST match the structure of `base` in the `drone-static-upload.sh` script in +# order to function correctly +prefix="session-android-" +suffix="-${DRONE_COMMIT:0:9}-universal.tar.xz" + +# Extracting head.label using string manipulation +echo "Extracting repo information for 'https://api.github.com/repos/${DRONE_REPO}/pulls/${DRONE_PULL_REQUEST}'" +pr_info=$(curl -s https://api.github.com/repos/${DRONE_REPO}/pulls/${DRONE_PULL_REQUEST}) +pr_info_clean=$(echo "$pr_info" | tr -d '[:space:]') +head_info=$(echo "$pr_info_clean" | sed -n 's/.*"head"\(.*\)"base".*/\1/p') +fork_repo=$(echo "$head_info" | grep -o '"full_name":"[^"]*' | sed 's/"full_name":"//') +fork_branch=$(echo "$head_info" | grep -o '"ref":"[^"]*' | sed 's/"ref":"//') +upload_dir="https://oxen.rocks/${fork_repo}/${fork_branch}" + +echo "Starting to poll ${upload_dir}/ every 10s to check for a build matching '${prefix}.*${suffix}'" + +# Loop indefinitely the CI can timeout the script if it takes too long +total_poll_duration=0 +max_poll_duration=$((30 * 60)) # Poll for a maximum of 30 mins + +while true; do + # Need to add the trailing '/' or else we get a '301' response + build_artifacts_html=$(curl -s "${upload_dir}/") + + if [ $? != 0 ]; then + echo -e "\n\n\n\n\e[31;1mFailed to retrieve build artifact list\e[0m\n\n\n" + exit 1 + fi + + # Extract 'session-ios...' titles using grep and awk then look for the target file + current_build_artifacts=$(echo "$build_artifacts_html" | grep -o "href=\"${prefix}[^\"]*" | sed 's/href="//') + target_file=$(echo "$current_build_artifacts" | grep -o "${prefix}.*${suffix}" | tail -n 1) + + if [ -n "$target_file" ]; then + echo -e "\n\n\n\n\e[32;1mExisting build artifact at ${upload_dir}/${target_file}\e[0m\n\n\n" + exit 0 + fi + + # Sleep for 10 seconds before checking again + sleep 10 + total_poll_duration=$((total_poll_duration + 10)) + + if [ $total_poll_duration -gt $max_poll_duration ]; then + echo -e "\n\n\n\n\e[31;1mCould not find existing build artifact after polling for 30 minutes\e[0m\n\n\n" + exit 1 + fi +done