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/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..b650b98b11 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "libsession-util/libsession-util"] + path = libsession-util/libsession-util + url = https://github.com/oxen-io/libsession-util.git diff --git a/.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 e78207d964..48b4412ddd 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -32,6 +32,13 @@ Setting up a development environment and building from Android Studio 4. Android Studio should detect the presence of a project file and ask you whether to open it. Click "yes". 5. Default config options should be good enough. 6. Project initialization and building should proceed. +7. Clone submodules with `git submodule update --init --recursive` + +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 341fd42841..17eaebf5fe 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -# Session Android +# Session Android [Download on the Google Play Store](https://getsession.org/android) Add the [F-Droid repo](https://fdroid.getsession.org/) -[Download the APK from here](https://github.com/loki-project/session-android/releases/latest) +[Download the APK from here](https://github.com/oxen-io/session-android/releases/latest) ## Summary Session integrates directly with [Oxen Service Nodes](https://docs.oxen.io/about-the-oxen-blockchain/oxen-service-nodes), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as servers which store messages offline, and a set of nodes which allow for onion routing functionality obfuscating users' IP addresses. For a full understanding of how Session works, read the [Session Whitepaper](https://getsession.org/whitepaper). -<img src="https://i.imgur.com/dO9f7Hg.jpg" width="320" /> +<img src="https://i.imgur.com/wcdAGBh.png" width="320" /> ## Want to contribute? Found a bug or have a feature request? diff --git a/app/build.gradle b/app/build.gradle index 49ecd7a8f5..eb2c16e953 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,24 +1,29 @@ + buildscript { repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:4.2.2' + classpath "com.android.tools.build:gradle:$gradlePluginVersion" classpath files('libs/gradle-witness.jar') classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion" - classpath "com.google.gms:google-services:4.3.10" + classpath "com.google.gms:google-services:$googleServicesVersion" classpath "com.google.dagger:hilt-android-gradle-plugin:$daggerVersion" } } +plugins { + id 'kotlin-kapt' + id 'com.google.dagger.hilt.android' +} + apply plugin: 'com.android.application' 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' @@ -26,32 +31,244 @@ configurations.all { exclude module: "commons-logging" } +def canonicalVersionCode = 373 +def canonicalVersionName = "1.18.4" + +def postFixSize = 10 +def abiPostFix = ['armeabi-v7a' : 1, + 'arm64-v8a' : 2, + 'x86' : 3, + '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' + useLibrary 'org.apache.http.legacy' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + packagingOptions { + exclude 'LICENSE.txt' + exclude 'LICENSE' + exclude 'NOTICE' + exclude 'asm-license.txt' + exclude 'META-INF/LICENSE' + exclude 'META-INF/NOTICE' + exclude 'META-INF/proguard/androidx-annotations.pro' + } + + splits { + abi { + enable true + reset() + include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' + universalApk true + } + } + + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion '1.4.7' + } + + defaultConfig { + versionCode canonicalVersionCode * postFixSize + versionName canonicalVersionName + + minSdkVersion androidMinimumSdkVersion + targetSdkVersion androidTargetSdkVersion + + multiDexEnabled = true + + vectorDrawables.useSupportLibrary = true + 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\"" + buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}' + buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode" + + resConfigs autoResConfig() + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + // The following argument makes the Android Test Orchestrator run its + // "pm clear" command after each test invocation. This command ensures + // that the app's state is completely cleared between tests. + testInstrumentationRunnerArguments clearPackageData: 'true' + testOptions { + execution 'ANDROIDX_TEST_ORCHESTRATOR' + } + } + + sourceSets { + String sharedTestDir = 'src/sharedTest/java' + test.java.srcDirs += sharedTestDir + androidTest.java.srcDirs += sharedTestDir + } + + buildTypes { + release { + 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', '\"\"' + } + } + + applicationVariants.all { variant -> + variant.outputs.each { output -> + def abiName = output.getFilter("ABI") ?: 'universal' + def postFix = abiPostFix.get(abiName, 0) + + if (postFix >= postFixSize) throw new AssertionError("postFix is too large") + output.outputFileName = output.outputFileName = "session-${variant.versionName}-${abiName}.apk" + output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix + } + } + + lintOptions { + abortOnError true + baseline file("lint-baseline.xml") + } + + testOptions { + unitTests { + includeAndroidResources = true + } + } + + buildFeatures { + 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 'androidx.appcompat:appcompat:1.3.1' - implementation 'androidx.recyclerview:recyclerview:1.1.0' - implementation 'com.google.android.material:material:1.2.1' + + 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:1.1.1' + 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.3' - implementation 'androidx.constraintlayout:constraintlayout:2.1.3' + implementation 'androidx.exifinterface:exifinterface:1.3.4' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-process:$lifecycleVersion" - implementation 'androidx.activity:activity-ktx:1.2.2' - implementation 'androidx.fragment:fragment-ktx:1.3.2' - implementation "androidx.core:core-ktx:1.3.2" - implementation "androidx.work:work-runtime-ktx:2.4.0" - implementation ("com.google.firebase:firebase-messaging:18.0.0") { + 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' @@ -91,20 +308,17 @@ dependencies { exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection' } implementation 'com.annimon:stream:1.1.8' - implementation 'com.takisoft.fix:colorpicker:1.0.1' implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4' implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2' - implementation 'org.signal:android-database-sqlcipher:3.5.9-S3' - implementation ('com.googlecode.ez-vcard:ez-vcard:0.9.11') { - exclude group: 'com.fasterxml.jackson.core' - exclude group: 'org.freemarker' - } + implementation 'androidx.sqlite:sqlite-ktx:2.3.1' + implementation 'net.zetetic:sqlcipher-android:4.5.4@aar' implementation project(":libsignal") implementation project(":libsession") + implementation project(":libsession-util") implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxJsonVersion" implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version" implementation project(":liblazysodium") - implementation "net.java.dev.jna:jna:5.8.0@aar" + implementation "net.java.dev.jna:jna:5.12.1@aar" implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" @@ -113,183 +327,59 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" implementation "nl.komponents.kovenant:kovenant-android:$kovenantVersion" - implementation "com.github.lelloman:android-identicons:v11" - implementation "com.prof.rssparser:rssparser:2.0.4" implementation "com.jakewharton.rxbinding3:rxbinding:3.1.0" implementation "com.github.tbruyelle:rxpermissions:0.10.2" implementation "com.github.ybq:Android-SpinKit:1.4.0" implementation "com.opencsv:opencsv:4.6" - testImplementation 'junit:junit:4.12' + 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 'org.powermock:powermock-api-mockito:1.6.1' - testImplementation 'org.powermock:powermock-module-junit4:1.6.1' - testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.1' - testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1' - testImplementation 'androidx.test:core:1.3.0' - testImplementation "androidx.arch.core:core-testing:2.1.0" + androidTestImplementation "org.mockito:mockito-android:4.11.0" + androidTestImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" + testImplementation "androidx.test:core:$testCoreVersion" + testImplementation "androidx.arch.core:core-testing:2.2.0" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" // Core library - androidTestImplementation 'androidx.test:core:1.4.0' + androidTestImplementation "androidx.test:core:$testCoreVersion" + + androidTestImplementation('com.adevinta.android:barista:4.2.0') { + exclude group: 'org.jetbrains.kotlin' + } // AndroidJUnitRunner and JUnit Rules - androidTestImplementation 'androidx.test:runner:1.4.0' - androidTestImplementation 'androidx.test:rules:1.4.0' + androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'androidx.test:rules:1.5.0' // Assertions - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.ext:truth:1.4.0' - androidTestImplementation 'com.google.truth:truth:1.0' + 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.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-web:3.4.0' - androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.4.0' - androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.4.0' - androidTestUtil 'androidx.test:orchestrator:1.4.0' + 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' -} -def canonicalVersionCode = 309 -def canonicalVersionName = "1.16.0" + 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" -def postFixSize = 10 -def abiPostFix = ['armeabi-v7a' : 1, - 'arm64-v8a' : 2, - 'x86' : 3, - 'x86_64' : 4, - 'universal' : 5] - -android { - compileSdkVersion androidCompileSdkVersion - buildToolsVersion '29.0.3' - useLibrary 'org.apache.http.legacy' - - dexOptions { - javaMaxHeapSize "4g" - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - packagingOptions { - exclude 'LICENSE.txt' - exclude 'LICENSE' - exclude 'NOTICE' - exclude 'asm-license.txt' - exclude 'META-INF/LICENSE' - exclude 'META-INF/NOTICE' - exclude 'META-INF/proguard/androidx-annotations.pro' - } - - splits { - abi { - enable true - reset() - include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' - universalApk true - } - } - - defaultConfig { - versionCode canonicalVersionCode * postFixSize - versionName canonicalVersionName - - minSdkVersion androidMinimumSdkVersion - targetSdkVersion androidCompileSdkVersion - - multiDexEnabled = true - - vectorDrawables.useSupportLibrary = true - project.ext.set("archivesBaseName", "session") - - buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L" - buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\"" - buildConfigField "int", "CONTENT_PROXY_PORT", "443" - buildConfigField "String", "USER_AGENT", "\"OWA\"" - buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}' - buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode" - - resConfigs autoResConfig() - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - // The following argument makes the Android Test Orchestrator run its - // "pm clear" command after each test invocation. This command ensures - // that the app's state is completely cleared between tests. - testInstrumentationRunnerArguments clearPackageData: 'true' - testOptions { - execution 'ANDROIDX_TEST_ORCHESTRATOR' - } - } - - sourceSets { - String sharedTestDir = 'src/sharedTest/java' - test.java.srcDirs += sharedTestDir - androidTest.java.srcDirs += sharedTestDir - } - - buildTypes { - release { - minifyEnabled false - } - debug { - minifyEnabled false - } - } - - flavorDimensions "distribution" - productFlavors { - play { - ext.websiteUpdateUrl = "null" - buildConfigField "boolean", "PLAY_STORE_DISABLED", "false" - buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl" - } - - website { - ext.websiteUpdateUrl = "https://github.com/oxen-io/session-android/releases" - buildConfigField "boolean", "PLAY_STORE_DISABLED", "true" - buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\"" - } - } - - applicationVariants.all { variant -> - variant.outputs.each { output -> - def abiName = output.getFilter("ABI") ?: 'universal' - def postFix = abiPostFix.get(abiName, 0) - - if (postFix >= postFixSize) throw new AssertionError("postFix is too large") - output.outputFileName = output.outputFileName = "session-${variant.versionName}-${abiName}.apk" - output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix - } - } - - lintOptions { - abortOnError true - baseline file("lint-baseline.xml") - } - - testOptions { - unitTests { - includeAndroidResources = true - } - } - - buildFeatures { - dataBinding true - viewBinding true - } + implementation 'androidx.compose.foundation:foundation-layout:1.5.2' + implementation 'androidx.compose.material:material:1.5.2' } static def getLastCommitTimestamp() { @@ -310,3 +400,8 @@ def autoResConfig() { .collect { matcher -> matcher.group(1) } .sort() } + +// Allow references to generated code +kapt { + correctErrorTypes = true +} diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt index 087d486893..a20a3a2a67 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -1,5 +1,6 @@ package network.loki.messenger +import android.Manifest import android.app.Instrumentation import android.content.ClipboardManager import android.content.Context @@ -21,6 +22,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry +import com.adevinta.android.barista.interaction.PermissionGranter import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf @@ -85,6 +87,8 @@ class HomeActivityTests { } onView(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click()) onView(withId(R.id.registerButton)).perform(ViewActions.click()) + // allow notification permission + PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS) } private fun goToMyChat() { @@ -100,6 +104,7 @@ class HomeActivityTests { copied = clipboardManager.primaryClip!!.getItemAt(0).text.toString() } onView(withId(R.id.publicKeyEditText)).perform(ViewActions.typeText(copied)) + onView(withId(R.id.publicKeyEditText)).perform(ViewActions.closeSoftKeyboard()) onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click()) } @@ -153,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 new file mode 100644 index 0000000000..157085135e --- /dev/null +++ b/app/src/androidTest/java/network/loki/messenger/LibSessionTests.kt @@ -0,0 +1,195 @@ +package network.loki.messenger + +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.Contacts +import network.loki.messenger.libsession_util.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 +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import kotlin.random.Random + +@RunWith(AndroidJUnit4::class) +@SmallTest +class LibSessionTests { + + private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() } + private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray()) + private fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey + + private var fakeHashI = 0 + private val nextFakeHash: String + get() = "fakehash${fakeHashI++}" + + private fun maybeGetUserInfo(): Pair<ByteArray, String>? { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + val prefs = appContext.prefs + val localUserPublicKey = prefs.getLocalNumber() + val secretKey = with(appContext) { + val edKey = KeyPairUtilities.getUserED25519KeyPair(this) ?: return null + edKey.secretKey.asBytes + } + return if (localUserPublicKey == null || secretKey == null) null + else secretKey to localUserPublicKey + } + + private fun buildContactMessage(contactList: List<Contact>): ByteArray { + val (key,_) = maybeGetUserInfo()!! + 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()) + } + + @Before + fun setupUser() { + PreferenceManager.getDefaultSharedPreferences(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext).edit { + putBoolean(TextSecurePreferences.HAS_FORCED_NEW_CONFIG, true).apply() + } + val newBytes = randomSeedBytes().toByteArray() + val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + val kp = KeyPairUtilities.generate(newBytes) + KeyPairUtilities.store(context, kp.seed, kp.ed25519KeyPair, kp.x25519KeyPair) + val registrationID = KeyHelper.generateRegistrationId(false) + TextSecurePreferences.setLocalRegistrationId(context, registrationID) + TextSecurePreferences.setLocalNumber(context, kp.x25519KeyPair.hexEncodedPublicKey) + TextSecurePreferences.setRestorationTime(context, 0) + TextSecurePreferences.setHasViewedSeed(context, false) + } + + @Test + fun migration_one_to_ones() { + val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as ApplicationContext + val storageSpy = spy(app.storage) + app.storage = storageSpy + + val newContactId = randomSessionId() + val singleContact = Contact( + id = newContactId, + approved = true, + expiryMode = ExpiryMode.NONE + ) + val newContactMerge = buildContactMessage(listOf(singleContact)) + val contacts = MessagingModuleConfiguration.shared.configFactory.contacts!! + fakePollNewConfig(contacts, newContactMerge) + verify(storageSpy).addLibSessionContacts(argThat { + first().let { it.id == newContactId && it.approved } && size == 1 + }, 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 681fc00c17..79d55b37f8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,8 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - package="network.loki.messenger"> + xmlns:tools="http://schemas.android.com/tools"> <uses-sdk tools:overrideLibrary="com.amulyakhare.textdrawable,com.astuetz.pagerslidingtabstrip,pl.tajchert.waitingdots,com.h6ah4i.android.multiselectlistpreferencecompat,android.support.v13,com.davemorrissey.labs.subscaleview,com.tomergoldst.tooltips,com.klinker.android.send_message,com.takisoft.colorpicker,android.support.v14.preference" /> @@ -30,11 +29,19 @@ android:name="android.hardware.touchscreen" android:required="false" /> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.BLUETOOTH" /> + <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" /> @@ -100,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" @@ -172,8 +174,12 @@ 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" android:name="org.thoughtcrime.securesms.ShareActivity" android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" android:excludeFromRecents="true" @@ -220,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" @@ -305,22 +309,19 @@ 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" android:permission="android.permission.BIND_CHOOSER_TARGET_SERVICE"> <intent-filter> <action android:name="android.service.chooser.ChooserTargetService" /> @@ -398,57 +399,49 @@ android:authorities="network.loki.securesms.database.recipient" android:exported="false" /> - <receiver android:name="org.thoughtcrime.securesms.service.BootReceiver"> + <receiver android:name="org.thoughtcrime.securesms.service.BootReceiver" + android:exported="true"> <intent-filter> <action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="network.loki.securesms.RESTART" /> </intent-filter> </receiver> - <receiver android:name="org.thoughtcrime.securesms.service.LocalBackupListener"> + <receiver android:name="org.thoughtcrime.securesms.service.PersistentConnectionBootListener" + android:exported="true"> <intent-filter> <action android:name="android.intent.action.BOOT_COMPLETED" /> </intent-filter> </receiver> - <receiver android:name="org.thoughtcrime.securesms.service.PersistentConnectionBootListener"> - <intent-filter> - <action android:name="android.intent.action.BOOT_COMPLETED" /> - </intent-filter> - </receiver> - <receiver android:name="org.thoughtcrime.securesms.notifications.LocaleChangedReceiver"> + <receiver android:name="org.thoughtcrime.securesms.notifications.LocaleChangedReceiver" + android:exported="true"> <intent-filter> <action android:name="android.intent.action.LOCALE_CHANGED" /> </intent-filter> </receiver> - <receiver android:name="org.thoughtcrime.securesms.notifications.DeleteNotificationReceiver"> + <receiver android:name="org.thoughtcrime.securesms.notifications.DeleteNotificationReceiver" + android:exported="true"> <intent-filter> <action android:name="network.loki.securesms.DELETE_NOTIFICATION" /> </intent-filter> </receiver> <receiver android:name="org.thoughtcrime.securesms.service.PanicResponderListener" - android:exported="true"> + android:exported="false"> <intent-filter> <action android:name="info.guardianproject.panic.action.TRIGGER" /> </intent-filter> </receiver> <receiver android:name="org.thoughtcrime.securesms.notifications.BackgroundPollWorker$BootBroadcastReceiver" - android:enabled="true"> + android:enabled="true" + android:exported="true"> <intent-filter> <action android:name="android.intent.action.BOOT_COMPLETED" /> </intent-filter> </receiver> - <service - android:name="org.thoughtcrime.securesms.jobmanager.JobSchedulerScheduler$SystemService" - android:enabled="@bool/enable_job_service" - android:permission="android.permission.BIND_JOB_SERVICE" - tools:targetApi="26" /> <service android:name="org.thoughtcrime.securesms.jobmanager.KeepAliveService" android:enabled="@bool/enable_alarm_manager" /> - <receiver - android:name="org.thoughtcrime.securesms.jobmanager.AlarmManagerScheduler$RetryReceiver" - android:enabled="@bool/enable_alarm_manager" /> <!-- Probably don't need this one --> <uses-library android:name="com.sec.android.app.multiwindow" android:required="false" /> diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 01bc1f38ae..03b56d6b61 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -40,6 +40,8 @@ import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPol import org.session.libsession.messaging.sending_receiving.pollers.Poller; import org.session.libsession.snode.SnodeModule; import org.session.libsession.utilities.Address; +import org.session.libsession.utilities.ConfigFactoryUpdateListener; +import org.session.libsession.utilities.Device; import org.session.libsession.utilities.ProfilePictureUtilities; import org.session.libsession.utilities.SSKEnvironment; import org.session.libsession.utilities.TextSecurePreferences; @@ -47,6 +49,7 @@ import org.session.libsession.utilities.Util; import org.session.libsession.utilities.WindowDebouncer; import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper; import org.session.libsession.utilities.dynamiclanguage.LocaleParser; +import org.session.libsignal.utilities.HTTP; import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.Log; import org.session.libsignal.utilities.ThreadUtils; @@ -54,33 +57,30 @@ 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.JobDatabase; +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; import org.thoughtcrime.securesms.database.model.EmojiSearchData; +import org.thoughtcrime.securesms.dependencies.AppComponent; +import org.thoughtcrime.securesms.dependencies.ConfigFactory; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.dependencies.DatabaseModule; import org.thoughtcrime.securesms.emoji.EmojiSource; import org.thoughtcrime.securesms.groups.OpenGroupManager; -import org.thoughtcrime.securesms.groups.OpenGroupMigrator; import org.thoughtcrime.securesms.home.HomeActivity; -import org.thoughtcrime.securesms.jobmanager.JobManager; -import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer; -import org.thoughtcrime.securesms.jobs.FastJobStorage; -import org.thoughtcrime.securesms.jobs.JobManagerFactories; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.logging.AndroidLogger; 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; -import org.thoughtcrime.securesms.service.UpdateApkRefreshListener; import org.thoughtcrime.securesms.sskenvironment.ProfileManager; import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager; import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository; @@ -111,6 +111,8 @@ 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; /** * Will be called once when the TextSecure process is created. @@ -121,7 +123,7 @@ import network.loki.messenger.BuildConfig; * @author Moxie Marlinspike */ @HiltAndroidApp -public class ApplicationContext extends Application implements DefaultLifecycleObserver { +public class ApplicationContext extends Application implements DefaultLifecycleObserver, ConfigFactoryUpdateListener { public static final String PREFERENCES_NAME = "SecureSMS-Preferences"; @@ -130,7 +132,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO private ExpiringMessageManager expiringMessageManager; private TypingStatusRepository typingStatusRepository; private TypingStatusSender typingStatusSender; - private JobManager jobManager; private ReadReceiptManager readReceiptManager; private ProfileManager profileManager; public MessageNotifier messageNotifier = null; @@ -143,10 +144,13 @@ public class ApplicationContext extends Application implements DefaultLifecycleO private PersistentLogger persistentLogger; @Inject LokiAPIDatabase lokiAPIDatabase; - @Inject Storage storage; + @Inject public Storage storage; + @Inject Device device; @Inject MessageDataProvider messageDataProvider; - @Inject JobDatabase jobDatabase; @Inject TextSecurePreferences textSecurePreferences; + @Inject PushRegistry pushRegistry; + @Inject ConfigFactory configFactory; + @Inject LastSentTimestampCache lastSentTimestampCache; CallMessageProcessor callMessageProcessor; MessagingModuleConfiguration messagingModuleConfiguration; @@ -165,7 +169,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO } public TextSecurePreferences getPrefs() { - return textSecurePreferences; + return EntryPoints.get(getApplicationContext(), AppComponent.class).getPrefs(); } public DatabaseComponent getDatabaseComponent() { @@ -194,18 +198,31 @@ public class ApplicationContext extends Application implements DefaultLifecycleO return this.persistentLogger; } + @Override + 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, 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)); - // migrate session open group data - OpenGroupMigrator.migrate(getDatabaseComponent()); - // end migration + ()-> KeyPairUtilities.INSTANCE.getUserED25519KeyPair(this), + configFactory, + lastSentTimestampCache + ); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); Log.i(TAG, "onCreate()"); startKovenant(); @@ -219,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(); @@ -230,12 +243,14 @@ public class ApplicationContext extends Application implements DefaultLifecycleO initializeProfileManager(); initializePeriodicTasks(); SSKEnvironment.Companion.configure(getTypingStatusRepository(), getReadReceiptManager(), getProfileManager(), messageNotifier, getExpiringMessageManager()); - initializeJobManager(); initializeWebRtc(); initializeBlobProvider(); resubmitProfilePictureIfNeeded(); loadEmojiSearchIndexIfNeeded(); EmojiSource.refresh(); + + NetworkConstraint networkConstraint = new NetworkConstraint.Factory(this).create(); + HTTP.INSTANCE.setConnectedToNetwork(networkConstraint::isMet); } @Override @@ -244,6 +259,12 @@ public class ApplicationContext extends Application implements DefaultLifecycleO Log.i(TAG, "App is now visible."); KeyCachingService.onAppForegrounded(this); + // If the user account hasn't been created or onboarding wasn't finished then don't start + // the pollers + if (TextSecurePreferences.getLocalNumber(this) == null || !TextSecurePreferences.hasSeenWelcomeScreen(this)) { + return; + } + ThreadUtils.queue(()->{ if (poller != null) { poller.setCaughtUp(false); @@ -264,7 +285,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO if (poller != null) { poller.stopIfNeeded(); } - ClosedGroupPollerV2.getShared().stop(); + ClosedGroupPollerV2.getShared().stopAll(); } @Override @@ -278,10 +299,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO LocaleParser.Companion.configure(new LocaleParseHelper()); } - public JobManager getJobManager() { - return jobManager; - } - public ExpiringMessageManager getExpiringMessageManager() { return expiringMessageManager; } @@ -344,16 +361,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionLogger(originalHandler)); } - private void initializeJobManager() { - this.jobManager = new JobManager(this, new JobManager.Configuration.Builder() - .setDataSerializer(new JsonDataSerializer()) - .setJobFactories(JobManagerFactories.getJobFactories(this)) - .setConstraintFactories(JobManagerFactories.getConstraintFactories(this)) - .setConstraintObservers(JobManagerFactories.getConstraintObservers(this)) - .setJobStorage(new FastJobStorage(jobDatabase)) - .build()); - } - private void initializeExpiringMessageManager() { this.expiringMessageManager = new ExpiringMessageManager(this); } @@ -367,7 +374,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO } private void initializeProfileManager() { - this.profileManager = new ProfileManager(); + this.profileManager = new ProfileManager(this, configFactory); } private void initializeTypingStatusSender() { @@ -376,10 +383,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO private void initializePeriodicTasks() { BackgroundPollWorker.schedulePeriodic(this); - - if (BuildConfig.PLAY_STORE_DISABLED) { - UpdateApkRefreshListener.schedule(this); - } } private void initializeWebRtc() { @@ -430,29 +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; - 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; @@ -460,7 +440,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO poller.setUserPublicKey(userPublicKey); return; } - poller = new Poller(); + poller = new Poller(configFactory, new Timer()); } public void startPollingIfNeeded() { @@ -481,6 +461,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO if (now - lastProfilePictureUpload <= 14 * 24 * 60 * 60 * 1000) return; ThreadUtils.queue(() -> { // Don't generate a new profile key here; we do that when the user changes their profile picture + Log.d("Loki-Avatar", "Uploading Avatar Started"); String encodedProfileKey = TextSecurePreferences.getProfileKey(ApplicationContext.this); try { // Read the file into a byte array @@ -497,10 +478,11 @@ public class ApplicationContext extends Application implements DefaultLifecycleO ProfilePictureUtilities.INSTANCE.upload(profilePicture, encodedProfileKey, ApplicationContext.this).success(unit -> { // Update the last profile picture upload date TextSecurePreferences.setLastProfilePictureUpload(ApplicationContext.this, new Date().getTime()); + Log.d("Loki-Avatar", "Uploading Avatar Finished"); return Unit.INSTANCE; }); - } catch (Exception exception) { - // Do nothing + } catch (Exception e) { + Log.e("Loki-Avatar", "Uploading avatar failed."); } }); } @@ -520,24 +502,21 @@ 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(); - if (!deleteDatabase("signal.db")) { + if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) { Log.d("Loki", "Failed to delete database."); } + configFactory.keyPairChanged(); Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java index 7d82c760cc..a99fe83430 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BaseActionBarActivity.java @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms; +import static android.os.Build.VERSION.SDK_INT; import static org.session.libsession.utilities.TextSecurePreferences.SELECTED_ACCENT_COLOR; import android.app.ActivityManager; @@ -18,6 +19,7 @@ import androidx.appcompat.app.AppCompatActivity; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageActivityHelper; import org.session.libsession.utilities.dynamiclanguage.DynamicLanguageContextWrapper; +import org.thoughtcrime.securesms.conversation.v2.WindowUtil; import org.thoughtcrime.securesms.util.ActivityUtilitiesKt; import org.thoughtcrime.securesms.util.ThemeState; import org.thoughtcrime.securesms.util.UiModeUtilities; @@ -28,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(); @@ -59,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) { @@ -92,6 +105,11 @@ public abstract class BaseActionBarActivity extends AppCompatActivity { if (!currentThemeState.equals(ActivityUtilitiesKt.themeState(getPreferences()))) { recreate(); } + + // apply lightStatusBar manually as API 26 does not update properly via applyTheme + // https://issuetracker.google.com/issues/65883460?pli=1 + if (SDK_INT >= 26 && SDK_INT <= 27) WindowUtil.setLightStatusBarFromTheme(this); + if (SDK_INT == 27) WindowUtil.setLightNavigationBarFromTheme(this); } @Override 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/DeleteMediaDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt new file mode 100644 index 0000000000..af38c31ff3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms + +import android.content.Context +import network.loki.messenger.R + +class DeleteMediaDialog { + companion object { + @JvmStatic + fun show(context: Context, recordCount: Int, doDelete: Runnable) = context.showSessionDialog { + iconAttribute(R.attr.dialog_alert_icon) + title( + context.resources.getQuantityString( + R.plurals.MediaOverviewActivity_Media_delete_confirm_title, + recordCount, + recordCount + ) + ) + text( + context.resources.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message, + recordCount, + recordCount + ) + ) + button(R.string.delete) { doDelete.run() } + cancelButton() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt new file mode 100644 index 0000000000..0390a3007d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms + +import android.content.Context +import network.loki.messenger.R + +class DeleteMediaPreviewDialog { + companion object { + @JvmStatic + fun show(context: Context, doDelete: Runnable) { + context.showSessionDialog { + iconAttribute(R.attr.dialog_alert_icon) + title(R.string.MediaPreviewActivity_media_delete_confirmation_title) + text(R.string.MediaPreviewActivity_media_delete_confirmation_message) + button(R.string.delete) { doDelete.run() } + cancelButton() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/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.java b/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.java deleted file mode 100644 index 469629ed3f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/ExpirationDialog.java +++ /dev/null @@ -1,88 +0,0 @@ -package org.thoughtcrime.securesms; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.TextView; - -import org.session.libsession.utilities.ExpirationUtil; - -import cn.carbswang.android.numberpickerview.library.NumberPickerView; -import network.loki.messenger.R; - -public class ExpirationDialog extends AlertDialog { - - protected ExpirationDialog(Context context) { - super(context); - } - - protected ExpirationDialog(Context context, int theme) { - super(context, theme); - } - - protected ExpirationDialog(Context context, boolean cancelable, OnCancelListener cancelListener) { - super(context, cancelable, cancelListener); - } - - public static void show(final Context context, - final int currentExpiration, - final @NonNull OnClickListener listener) - { - final View view = createNumberPickerView(context, currentExpiration); - - AlertDialog.Builder builder = new AlertDialog.Builder(context); - builder.setTitle(context.getString(R.string.ExpirationDialog_disappearing_messages)); - builder.setView(view); - builder.setPositiveButton(android.R.string.ok, (dialog, which) -> { - int selected = ((NumberPickerView)view.findViewById(R.id.expiration_number_picker)).getValue(); - listener.onClick(context.getResources().getIntArray(R.array.expiration_times)[selected]); - }); - builder.setNegativeButton(android.R.string.cancel, null); - builder.show(); - } - - private static View createNumberPickerView(final Context context, final int currentExpiration) { - final LayoutInflater inflater = LayoutInflater.from(context); - final View view = inflater.inflate(R.layout.expiration_dialog, null); - final NumberPickerView numberPickerView = view.findViewById(R.id.expiration_number_picker); - final TextView textView = view.findViewById(R.id.expiration_details); - final int[] expirationTimes = context.getResources().getIntArray(R.array.expiration_times); - final String[] expirationDisplayValues = new String[expirationTimes.length]; - - int selectedIndex = expirationTimes.length - 1; - - for (int i=0;i<expirationTimes.length;i++) { - expirationDisplayValues[i] = ExpirationUtil.getExpirationDisplayValue(context, expirationTimes[i]); - - if ((currentExpiration >= expirationTimes[i]) && - (i == expirationTimes.length -1 || currentExpiration < expirationTimes[i+1])) { - selectedIndex = i; - } - } - - numberPickerView.setDisplayedValues(expirationDisplayValues); - numberPickerView.setMinValue(0); - numberPickerView.setMaxValue(expirationTimes.length-1); - - NumberPickerView.OnValueChangeListener listener = (picker, oldVal, newVal) -> { - if (newVal == 0) { - textView.setText(R.string.ExpirationDialog_your_messages_will_not_expire); - } else { - textView.setText(context.getString(R.string.ExpirationDialog_your_messages_will_disappear_s_after_they_have_been_seen, picker.getDisplayedValues()[newVal])); - } - }; - - numberPickerView.setOnValueChangedListener(listener); - numberPickerView.setValue(selectedIndex); - listener.onValueChange(numberPickerView, selectedIndex, selectedIndex); - - return view; - } - - public interface OnClickListener { - public void onClick(int expirationTime); - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java index aad4c17008..0fd813cf4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaGalleryAdapter.java @@ -114,7 +114,7 @@ class MediaGalleryAdapter extends StickyHeaderGridAdapter { Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment()); if (slide != null) { - thumbnailView.setImageResource(glideRequests, slide, false, false); + thumbnailView.setImageResource(glideRequests, slide, false, null); } thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java index 53a909c5ac..95ba15c82e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaOverviewActivity.java @@ -54,6 +54,7 @@ import com.google.android.material.tabs.TabLayout; import org.session.libsession.messaging.messages.control.DataExtractionNotification; import org.session.libsession.messaging.sending_receiving.MessageSender; +import org.session.libsession.snode.SnodeAPI; import org.session.libsession.utilities.Address; import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; import org.thoughtcrime.securesms.database.MediaDatabase; @@ -75,6 +76,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Locale; +import kotlin.Unit; import network.loki.messenger.R; /** @@ -317,9 +319,9 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity { @SuppressWarnings("CodeBlock2Expr") @SuppressLint({"InlinedApi", "StaticFieldLeak"}) private void handleSaveMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) { - final Context context = getContext(); + final Context context = requireContext(); - SaveAttachmentTask.showWarningDialog(context, (dialogInterface, which) -> { + SaveAttachmentTask.showWarningDialog(context, mediaRecords.size(), () -> { Permissions.with(this) .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) .maxSdkVersion(Build.VERSION_CODES.P) @@ -361,53 +363,39 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity { }.execute(); }) .execute(); - }, mediaRecords.size()); + return Unit.INSTANCE; + }); } private void sendMediaSavedNotificationIfNeeded() { if (recipient.isGroupRecipient()) return; - DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(System.currentTimeMillis())); + DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset())); MessageSender.send(message, recipient.getAddress()); } @SuppressLint("StaticFieldLeak") private void handleDeleteMedia(@NonNull Collection<MediaDatabase.MediaRecord> mediaRecords) { int recordCount = mediaRecords.size(); - Resources res = getContext().getResources(); - String confirmTitle = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_title, - recordCount, - recordCount); - String confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message, - recordCount, - recordCount); - AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); - builder.setIconAttribute(R.attr.dialog_alert_icon); - builder.setTitle(confirmTitle); - builder.setMessage(confirmMessage); - builder.setCancelable(true); - - builder.setPositiveButton(R.string.delete, (dialogInterface, i) -> { - new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>(getContext(), - R.string.MediaOverviewActivity_Media_delete_progress_title, - R.string.MediaOverviewActivity_Media_delete_progress_message) - { - @Override - protected Void doInBackground(MediaDatabase.MediaRecord... records) { - if (records == null || records.length == 0) { - return null; - } - - for (MediaDatabase.MediaRecord record : records) { - AttachmentUtil.deleteAttachment(getContext(), record.getAttachment()); - } + DeleteMediaDialog.show( + requireContext(), + recordCount, + () -> new ProgressDialogAsyncTask<MediaDatabase.MediaRecord, Void, Void>( + requireContext(), + R.string.MediaOverviewActivity_Media_delete_progress_title, + R.string.MediaOverviewActivity_Media_delete_progress_message) { + @Override + protected Void doInBackground(MediaDatabase.MediaRecord... records) { + if (records == null || records.length == 0) { return null; } - }.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])); - }); - builder.setNegativeButton(android.R.string.cancel, null); - builder.show(); + for (MediaDatabase.MediaRecord record : records) { + AttachmentUtil.deleteAttachment(getContext(), record.getAttachment()); + } + return null; + } + }.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()]))); } private void handleSelectAllMedia() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index b21c7dac8c..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; @@ -60,6 +60,7 @@ import androidx.viewpager.widget.ViewPager; import org.session.libsession.messaging.messages.control.DataExtractionNotification; import org.session.libsession.messaging.sending_receiving.MessageSender; import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment; +import org.session.libsession.snode.SnodeAPI; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Util; import org.session.libsession.utilities.recipients.Recipient; @@ -84,6 +85,7 @@ import java.io.IOException; import java.util.Locale; import java.util.WeakHashMap; +import kotlin.Unit; import network.loki.messenger.R; /** @@ -144,6 +146,11 @@ 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()); + } public static Intent getPreviewIntent(Context context, Slide slide, MmsMessageRecord mms, Recipient threadRecipient) { Intent previewIntent = null; @@ -212,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); @@ -280,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); @@ -373,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); @@ -415,7 +413,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im MediaItem mediaItem = getCurrentMediaItem(); if (mediaItem == null) return; - SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> { + SaveAttachmentTask.showWarningDialog(this, 1, () -> { Permissions.with(this) .request(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) .maxSdkVersion(Build.VERSION_CODES.P) @@ -423,7 +421,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im .onAnyDenied(() -> Toast.makeText(this, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) .onAllGranted(() -> { SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this); - long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis(); + long saveDate = (mediaItem.date > 0) ? mediaItem.date : SnodeAPI.getNowWithOffset(); saveTask.executeOnExecutor( AsyncTask.THREAD_POOL_EXECUTOR, new Attachment(mediaItem.uri, mediaItem.type, saveDate, null)); @@ -432,12 +430,13 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im } }) .execute(); + return Unit.INSTANCE; }); } private void sendMediaSavedNotificationIfNeeded() { if (conversationRecipient.isGroupRecipient()) return; - DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(System.currentTimeMillis())); + DataExtractionNotification message = new DataExtractionNotification(new DataExtractionNotification.Kind.MediaSaved(SnodeAPI.getNowWithOffset())); MessageSender.send(message, conversationRecipient.getAddress()); } @@ -448,29 +447,20 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im return; } - AlertDialog.Builder builder = new AlertDialog.Builder(this); - builder.setIconAttribute(R.attr.dialog_alert_icon); - builder.setTitle(R.string.MediaPreviewActivity_media_delete_confirmation_title); - builder.setMessage(R.string.MediaPreviewActivity_media_delete_confirmation_message); - builder.setCancelable(true); - - builder.setPositiveButton(R.string.delete, (dialogInterface, which) -> { - new AsyncTask<Void, Void, Void>() { - @Override - protected Void doInBackground(Void... voids) { - if (mediaItem.attachment == null) { - return null; - } - AttachmentUtil.deleteAttachment(MediaPreviewActivity.this.getApplicationContext(), - mediaItem.attachment); - return null; - } - }.execute(); + DeleteMediaPreviewDialog.show(this, () -> { + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... voids) { + DatabaseAttachment attachment = mediaItem.attachment; + if (attachment != null) { + AttachmentUtil.deleteAttachment(getApplicationContext(), attachment); + } + return null; + } + }.execute(); finish(); }); - builder.setNegativeButton(android.R.string.cancel, null); - builder.show(); } @Override @@ -509,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) { @@ -529,20 +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) { - @SuppressWarnings("ConstantConditions") - 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); } } @@ -560,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 @@ -593,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; @@ -665,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<>(); @@ -675,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, @@ -690,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 @@ -771,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); } } @@ -800,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/MediaPreviewArgs.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewArgs.kt new file mode 100644 index 0000000000..00e2c3d6d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewArgs.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms + +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.mms.Slide + +data class MediaPreviewArgs( + val slide: Slide, + val mmsRecord: MmsMessageRecord?, + val thread: Recipient?, +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/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/MuteDialog.java b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java deleted file mode 100644 index acca9f8375..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.thoughtcrime.securesms; - -import android.content.Context; -import android.content.DialogInterface; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; - -import java.util.concurrent.TimeUnit; - -import network.loki.messenger.R; - -public class MuteDialog extends AlertDialog { - - - protected MuteDialog(Context context) { - super(context); - } - - protected MuteDialog(Context context, boolean cancelable, OnCancelListener cancelListener) { - super(context, cancelable, cancelListener); - } - - protected MuteDialog(Context context, int theme) { - super(context, theme); - } - - public static void show(final Context context, final @NonNull MuteSelectionListener listener) { - AlertDialog.Builder builder = new AlertDialog.Builder(context); - builder.setTitle(R.string.MuteDialog_mute_notifications); - builder.setItems(R.array.mute_durations, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, final int which) { - final long muteUntil; - - switch (which) { - case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(2); break; - case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break; - case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break; - case 4: muteUntil = Long.MAX_VALUE; break; - default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break; - } - - listener.onMuted(muteUntil); - } - }); - - builder.show(); - - } - - public interface MuteSelectionListener { - public void onMuted(long until); - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt new file mode 100644 index 0000000000..f294e387ff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms + +import android.content.Context +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import network.loki.messenger.R +import java.util.concurrent.TimeUnit + +fun showMuteDialog( + context: Context, + onMuteDuration: (Long) -> Unit +): AlertDialog = context.showSessionDialog { + title(R.string.MuteDialog_mute_notifications) + items(Option.values().map { it.stringRes }.map(context::getString).toTypedArray()) { + onMuteDuration(Option.values()[it].getTime()) + } +} + +private enum class Option(@StringRes val stringRes: Int, val getTime: () -> Long) { + ONE_HOUR(R.string.arrays__mute_for_one_hour, duration = TimeUnit.HOURS.toMillis(1)), + TWO_HOURS(R.string.arrays__mute_for_two_hours, duration = TimeUnit.DAYS.toMillis(2)), + ONE_DAY(R.string.arrays__mute_for_one_day, duration = TimeUnit.DAYS.toMillis(1)), + SEVEN_DAYS(R.string.arrays__mute_for_seven_days, duration = TimeUnit.DAYS.toMillis(7)), + FOREVER(R.string.arrays__mute_forever, getTime = { Long.MAX_VALUE }); + + constructor(@StringRes stringRes: Int, duration: Long): this(stringRes, { System.currentTimeMillis() + duration }) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java index 63b42c4936..afc993df8a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java @@ -210,8 +210,7 @@ public class PassphrasePromptActivity extends BaseActionBarActivity { try { signature = biometricSecretProvider.getOrCreateBiometricSignature(this); hasSignatureObject = true; - throw new InvalidKeyException("e"); - } catch (InvalidKeyException e) { + } catch (Exception e) { signature = null; hasSignatureObject = false; Log.e(TAG, "Error getting / creating signature", e); 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 new file mode 100644 index 0000000000..598977392b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/SessionDialogBuilder.kt @@ -0,0 +1,150 @@ +package org.thoughtcrime.securesms + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.Button +import android.widget.LinearLayout +import android.widget.LinearLayout.VERTICAL +import android.widget.Space +import android.widget.TextView +import androidx.annotation.AttrRes +import androidx.annotation.LayoutRes +import androidx.annotation.StringRes +import androidx.annotation.StyleRes +import androidx.appcompat.app.AlertDialog +import androidx.core.view.setMargins +import androidx.core.view.updateMargins +import androidx.fragment.app.Fragment +import network.loki.messenger.R +import org.thoughtcrime.securesms.util.toPx + +@DslMarker +@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE) +annotation class DialogDsl + +@DialogDsl +class SessionDialogBuilder(val context: Context) { + + private val dp20 = toPx(20, context.resources) + private val dp40 = toPx(40, context.resources) + private val 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 { setPadding(0, dp20, 0, 0) } + .apply { orientation = VERTICAL } + .also(dialogBuilder::setCustomTitle) + private val contentView = LinearLayout(context).apply { orientation = VERTICAL } + private val buttonLayout = LinearLayout(context) + + private val root = LinearLayout(context).apply { orientation = VERTICAL } + .also(dialogBuilder::setView) + .apply { + addView(contentView) + addView(buttonLayout) + } + + fun title(@StringRes id: Int) = title(context.getString(id)) + + fun title(text: CharSequence?) = title(text?.toString()) + fun title(text: String?) { + text(text, R.style.TextAppearance_AppCompat_Title) { setPadding(dp20, 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, 0) } + } + } + + private fun text(text: CharSequence?, @StyleRes style: Int, modify: TextView.() -> Unit) { + text ?: return + TextView(context, null, 0, style) + .apply { + setText(text) + textAlignment = View.TEXT_ALIGNMENT_CENTER + modify() + }.let(topView::addView) + + Space(context).apply { + layoutParams = LinearLayout.LayoutParams(0, dp20) + }.let(topView::addView) + } + + fun view(view: View) = contentView.addView(view) + + fun view(@LayoutRes layout: Int): View = LayoutInflater.from(context).inflate(layout, contentView) + + fun iconAttribute(@AttrRes icon: Int): AlertDialog.Builder = dialogBuilder.setIconAttribute(icon) + + fun singleChoiceItems( + options: Collection<String>, + currentSelected: Int = 0, + onSelect: (Int) -> Unit + ) = singleChoiceItems(options.toTypedArray(), currentSelected, onSelect) + + fun singleChoiceItems( + options: Array<String>, + currentSelected: Int = 0, + onSelect: (Int) -> Unit + ): AlertDialog.Builder = dialogBuilder.setSingleChoiceItems( + options, + currentSelected + ) { dialog, it -> onSelect(it); dialog.dismiss() } + + fun items( + options: Array<String>, + onSelect: (Int) -> Unit + ): AlertDialog.Builder = dialogBuilder.setItems( + options, + ) { dialog, it -> onSelect(it); dialog.dismiss() } + + fun destructiveButton( + @StringRes text: Int, + @StringRes contentDescription: Int = text, + listener: () -> Unit = {} + ) = button( + text, + contentDescription, + R.style.Widget_Session_Button_Dialog_DestructiveText, + ) { listener() } + + fun okButton(listener: (() -> Unit) = {}) = button(android.R.string.ok) { listener() } + fun cancelButton(listener: (() -> Unit) = {}) = button(android.R.string.cancel, R.string.AccessibilityId_cancel_button) { listener() } + + fun button( + @StringRes text: Int, + @StringRes contentDescriptionRes: Int = text, + @StyleRes style: Int = R.style.Widget_Session_Button_Dialog_UnimportantText, + dismiss: Boolean = true, + listener: (() -> Unit) = {} + ) = Button(context, null, 0, style).apply { + setText(text) + contentDescription = resources.getString(contentDescriptionRes) + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, dp60, 1f) + setOnClickListener { + listener.invoke() + if (dismiss) dismiss() + } + }.let(buttonLayout::addView) + + fun create(): AlertDialog = dialogBuilder.create().also { dialog = it } + fun show(): AlertDialog = dialogBuilder.show().also { dialog = it } +} + +fun Context.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog = + SessionDialogBuilder(this).apply { build() }.show() + +fun Fragment.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog = + SessionDialogBuilder(requireContext()).apply { build() }.show() +fun Fragment.createSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog = + SessionDialogBuilder(requireContext()).apply { build() }.create() diff --git a/app/src/main/java/org/thoughtcrime/securesms/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 fa0fce7bd3..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 @@ -74,10 +76,10 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) attachmentDatabase.setTransferState(messageID, attachmentId, attachmentState.value) } - override fun getMessageForQuote(timestamp: Long, author: Address): Pair<Long, Boolean>? { + override fun getMessageForQuote(timestamp: Long, author: Address): Triple<Long, Boolean, String>? { val messagingDatabase = DatabaseComponent.get(context).mmsSmsDatabase() val message = messagingDatabase.getMessageFor(timestamp, author) - return if (message != null) Pair(message.id, message.isMms) else null + return if (message != null) Triple(message.id, message.isMms, message.body) else null } override fun getAttachmentsAndLinkPreviewFor(mmsId: Long): List<Attachment> { @@ -176,35 +178,63 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) return messageDB.getMessageID(serverId, threadId) } + override fun getMessageIDs(serverIds: List<Long>, threadId: Long): Pair<List<Long>, List<Long>> { + val messageDB = DatabaseComponent.get(context).lokiMessageDatabase() + return messageDB.getMessageIDs(serverIds, threadId) + } + 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 updateMessageAsDeleted(timestamp: Long, author: String) { + 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, 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? { val database = DatabaseComponent.get(context).mmsSmsDatabase() val address = Address.fromSerialized(author) - val message = database.getMessageFor(timestamp, address) ?: return + val message = database.getMessageFor(timestamp, address) ?: return null val messagingDatabase: MessagingDatabase = if (message.isMms) DatabaseComponent.get(context).mmsDatabase() else DatabaseComponent.get(context).smsDatabase() - messagingDatabase.markAsDeleted(message.id, message.isRead) + messagingDatabase.markAsDeleted(message.id, message.isRead, message.hasMention) if (message.isOutgoing) { messagingDatabase.deleteMessage(message.id) } + + 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/attachments/ScreenshotObserver.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/ScreenshotObserver.kt index 94c7517eb0..9c7ca21e8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/ScreenshotObserver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/ScreenshotObserver.kt @@ -7,12 +7,21 @@ import android.os.Build import android.os.Handler import android.provider.MediaStore import androidx.annotation.RequiresApi +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer + +private const val TAG = "ScreenshotObserver" class ScreenshotObserver(private val context: Context, handler: Handler, private val screenshotTriggered: ()->Unit): ContentObserver(handler) { override fun onChange(selfChange: Boolean, uri: Uri?) { super.onChange(selfChange, uri) uri ?: return + + // There is an odd bug where we can get notified for changes to 'content://media/external' + // directly which is a protected folder, this code is to prevent that crash + if (uri.scheme == "content" && uri.host == "media" && uri.path == "/external") { return } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { queryRelativeDataColumn(uri) } else { @@ -26,22 +35,26 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private val projection = arrayOf( MediaStore.Images.Media.DATA ) - context.contentResolver.query( - uri, - projection, - null, - null, - null - )?.use { cursor -> - val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA) - while (cursor.moveToNext()) { - val path = cursor.getString(dataColumn) - if (path.contains("screenshot", true)) { - if (cache.add(uri.hashCode())) { - screenshotTriggered() + try { + context.contentResolver.query( + uri, + projection, + null, + null, + null + )?.use { cursor -> + val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA) + while (cursor.moveToNext()) { + val path = cursor.getString(dataColumn) + if (path.contains("screenshot", true)) { + if (cache.add(uri.hashCode())) { + screenshotTriggered() + } } } } + } catch (e: SecurityException) { + Log.e(TAG, e) } } @@ -51,28 +64,32 @@ class ScreenshotObserver(private val context: Context, handler: Handler, private MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.RELATIVE_PATH ) - context.contentResolver.query( - uri, - projection, - null, - null, - null - )?.use { cursor -> - val relativePathColumn = - cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH) - val displayNameColumn = - cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME) - while (cursor.moveToNext()) { - val name = cursor.getString(displayNameColumn) - val relativePath = cursor.getString(relativePathColumn) - if (name.contains("screenshot", true) or - relativePath.contains("screenshot", true)) { - if (cache.add(uri.hashCode())) { - screenshotTriggered() + + try { + context.contentResolver.query( + uri, + projection, + null, + null, + null + )?.use { cursor -> + val relativePathColumn = + cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH) + val displayNameColumn = + cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME) + while (cursor.moveToNext()) { + val name = cursor.getString(displayNameColumn) + val relativePath = cursor.getString(relativePathColumn) + if (name.contains("screenshot", true) or + relativePath.contains("screenshot", true)) { + if (cache.add(uri.hashCode())) { + screenshotTriggered() + } } } } + } catch (e: IllegalStateException) { + Log.e(TAG, e) } } - -} \ No newline at end of file +} 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/backup/BackupDialog.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java deleted file mode 100644 index 76342898b1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupDialog.java +++ /dev/null @@ -1,104 +0,0 @@ -package org.thoughtcrime.securesms.backup; - -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.widget.Button; -import android.widget.CheckBox; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; - -import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.util.BackupDirSelector; -import org.thoughtcrime.securesms.util.BackupUtil; - -import org.session.libsession.utilities.Util; - -import java.io.IOException; - -import network.loki.messenger.R; - -public class BackupDialog { - private static final String TAG = "BackupDialog"; - - public static void showEnableBackupDialog( - @NonNull Context context, - @NonNull SwitchPreferenceCompat preference, - @NonNull BackupDirSelector backupDirSelector) { - - String[] password = BackupUtil.generateBackupPassphrase(); - String passwordSt = Util.join(password, ""); - - AlertDialog dialog = new AlertDialog.Builder(context) - .setTitle(R.string.BackupDialog_enable_local_backups) - .setView(R.layout.backup_enable_dialog) - .setPositiveButton(R.string.BackupDialog_enable_backups, null) - .setNegativeButton(android.R.string.cancel, null) - .create(); - - dialog.setOnShowListener(created -> { - Button button = ((AlertDialog) created).getButton(AlertDialog.BUTTON_POSITIVE); - button.setOnClickListener(v -> { - CheckBox confirmationCheckBox = dialog.findViewById(R.id.confirmation_check); - if (confirmationCheckBox.isChecked()) { - backupDirSelector.selectBackupDir(true, uri -> { - try { - BackupUtil.enableBackups(context, passwordSt); - } catch (IOException e) { - Log.e(TAG, "Failed to activate backups.", e); - Toast.makeText(context, - context.getString(R.string.dialog_backup_activation_failed), - Toast.LENGTH_LONG) - .show(); - return; - } - - preference.setChecked(true); - created.dismiss(); - }); - } else { - Toast.makeText(context, R.string.BackupDialog_please_acknowledge_your_understanding_by_marking_the_confirmation_check_box, Toast.LENGTH_LONG).show(); - } - }); - }); - - dialog.show(); - - CheckBox checkBox = dialog.findViewById(R.id.confirmation_check); - TextView textView = dialog.findViewById(R.id.confirmation_text); - - ((TextView)dialog.findViewById(R.id.code_first)).setText(password[0]); - ((TextView)dialog.findViewById(R.id.code_second)).setText(password[1]); - ((TextView)dialog.findViewById(R.id.code_third)).setText(password[2]); - - ((TextView)dialog.findViewById(R.id.code_fourth)).setText(password[3]); - ((TextView)dialog.findViewById(R.id.code_fifth)).setText(password[4]); - ((TextView)dialog.findViewById(R.id.code_sixth)).setText(password[5]); - - textView.setOnClickListener(v -> checkBox.toggle()); - - dialog.findViewById(R.id.number_table).setOnClickListener(v -> { - ((ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("text", passwordSt)); - Toast.makeText(context, R.string.BackupDialog_copied_to_clipboard, Toast.LENGTH_SHORT).show(); - }); - - - } - - public static void showDisableBackupDialog(@NonNull Context context, @NonNull SwitchPreferenceCompat preference) { - new AlertDialog.Builder(context) - .setTitle(R.string.BackupDialog_delete_backups) - .setMessage(R.string.BackupDialog_disable_and_delete_all_local_backups) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.BackupDialog_delete_backups_statement, (dialog, which) -> { - BackupUtil.disableBackups(context, true); - preference.setChecked(false); - }) - .create() - .show(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupEvent.kt deleted file mode 100644 index 614dc30bba..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupEvent.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.thoughtcrime.securesms.backup - -data class BackupEvent constructor(val type: Type, val count: Int, val exception: Exception?) { - - enum class Type { - PROGRESS, FINISHED - } - - companion object { - @JvmStatic fun createProgress(count: Int) = BackupEvent(Type.PROGRESS, count, null) - @JvmStatic fun createFinished() = BackupEvent(Type.FINISHED, 0, null) - @JvmStatic fun createFinished(e: Exception?) = BackupEvent(Type.FINISHED, 0, e) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupPassphrase.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupPassphrase.java deleted file mode 100644 index eec2a2e588..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupPassphrase.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.thoughtcrime.securesms.backup; - -import android.content.Context; -import android.os.Build; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.thoughtcrime.securesms.crypto.KeyStoreHelper; -import org.session.libsignal.utilities.Log; -import org.session.libsession.utilities.TextSecurePreferences; - -/** - * Allows the getting and setting of the backup passphrase, which is stored encrypted on API >= 23. - */ -public class BackupPassphrase { - - private static final String TAG = BackupPassphrase.class.getSimpleName(); - - public static @Nullable String get(@NonNull Context context) { - String passphrase = TextSecurePreferences.getBackupPassphrase(context); - String encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context); - - if (Build.VERSION.SDK_INT < 23 || (passphrase == null && encryptedPassphrase == null)) { - return passphrase; - } - - if (encryptedPassphrase == null) { - Log.i(TAG, "Migrating to encrypted passphrase."); - set(context, passphrase); - encryptedPassphrase = TextSecurePreferences.getEncryptedBackupPassphrase(context); - } - - KeyStoreHelper.SealedData data = KeyStoreHelper.SealedData.fromString(encryptedPassphrase); - return new String(KeyStoreHelper.unseal(data)); - } - - public static void set(@NonNull Context context, @Nullable String passphrase) { - if (passphrase == null || Build.VERSION.SDK_INT < 23) { - TextSecurePreferences.setBackupPassphrase(context, passphrase); - TextSecurePreferences.setEncryptedBackupPassphrase(context, null); - } else { - KeyStoreHelper.SealedData encryptedPassphrase = KeyStoreHelper.seal(passphrase.getBytes()); - TextSecurePreferences.setEncryptedBackupPassphrase(context, encryptedPassphrase.serialize()); - TextSecurePreferences.setBackupPassphrase(context, null); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupPreferences.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupPreferences.kt deleted file mode 100644 index 8ddfc23a8b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupPreferences.kt +++ /dev/null @@ -1,101 +0,0 @@ -package org.thoughtcrime.securesms.backup - -import android.content.Context -import android.content.SharedPreferences -import android.os.Build -import android.preference.PreferenceManager -import android.preference.PreferenceManager.getDefaultSharedPreferencesName -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.backup.FullBackupImporter.PREF_PREFIX_TYPE_BOOLEAN -import org.thoughtcrime.securesms.backup.FullBackupImporter.PREF_PREFIX_TYPE_INT -import java.util.* - -object BackupPreferences { - // region Backup related - fun getBackupRecords(context: Context): List<BackupProtos.SharedPreference> { - val preferences = PreferenceManager.getDefaultSharedPreferences(context) - val prefsFileName: String - prefsFileName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - getDefaultSharedPreferencesName(context) - } else { - context.packageName + "_preferences" - } - val prefList: LinkedList<BackupProtos.SharedPreference> = LinkedList<BackupProtos.SharedPreference>() - addBackupEntryInt(prefList, preferences, prefsFileName, TextSecurePreferences.LOCAL_REGISTRATION_ID_PREF) - addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.LOCAL_NUMBER_PREF) - addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_NAME_PREF) - addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_AVATAR_URL_PREF) - addBackupEntryInt(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_AVATAR_ID_PREF) - addBackupEntryString(prefList, preferences, prefsFileName, TextSecurePreferences.PROFILE_KEY_PREF) - addBackupEntryBoolean(prefList, preferences, prefsFileName, TextSecurePreferences.IS_USING_FCM) - return prefList - } - - private fun addBackupEntryString( - outPrefList: MutableList<BackupProtos.SharedPreference>, - prefs: SharedPreferences, - prefFileName: String, - prefKey: String, - ) { - val value = prefs.getString(prefKey, null) - if (value == null) { - logBackupEntry(prefKey, false) - return - } - outPrefList.add(BackupProtos.SharedPreference.newBuilder() - .setFile(prefFileName) - .setKey(prefKey) - .setValue(value) - .build()) - logBackupEntry(prefKey, true) - } - - private fun addBackupEntryInt( - outPrefList: MutableList<BackupProtos.SharedPreference>, - prefs: SharedPreferences, - prefFileName: String, - prefKey: String, - ) { - val value = prefs.getInt(prefKey, -1) - if (value == -1) { - logBackupEntry(prefKey, false) - return - } - outPrefList.add(BackupProtos.SharedPreference.newBuilder() - .setFile(prefFileName) - .setKey(PREF_PREFIX_TYPE_INT + prefKey) // The prefix denotes the type of the preference. - .setValue(value.toString()) - .build()) - logBackupEntry(prefKey, true) - } - - private fun addBackupEntryBoolean( - outPrefList: MutableList<BackupProtos.SharedPreference>, - prefs: SharedPreferences, - prefFileName: String, - prefKey: String, - ) { - if (!prefs.contains(prefKey)) { - logBackupEntry(prefKey, false) - return - } - outPrefList.add(BackupProtos.SharedPreference.newBuilder() - .setFile(prefFileName) - .setKey(PREF_PREFIX_TYPE_BOOLEAN + prefKey) // The prefix denotes the type of the preference. - .setValue(prefs.getBoolean(prefKey, false).toString()) - .build()) - logBackupEntry(prefKey, true) - } - - private fun logBackupEntry(prefName: String, wasIncluded: Boolean) { - val sb = StringBuilder() - sb.append("Backup preference ") - sb.append(if (wasIncluded) "+ " else "- ") - sb.append('\"').append(prefName).append("\" ") - if (!wasIncluded) { - sb.append("(is empty and not included)") - } - Log.d("Loki", sb.toString()) - } // endregion -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupProtos.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupProtos.java deleted file mode 100644 index f3b78606f4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupProtos.java +++ /dev/null @@ -1,6778 +0,0 @@ -// Generated by the protocol buffer compiler. DO NOT EDIT! -// source: Backups.proto - -package org.thoughtcrime.securesms.backup; - -public final class BackupProtos { - private BackupProtos() {} - public static void registerAllExtensions( - com.google.protobuf.ExtensionRegistry registry) { - } - public interface SqlStatementOrBuilder - extends com.google.protobuf.MessageOrBuilder { - - // optional string statement = 1; - /** - * <code>optional string statement = 1;</code> - */ - boolean hasStatement(); - /** - * <code>optional string statement = 1;</code> - */ - java.lang.String getStatement(); - /** - * <code>optional string statement = 1;</code> - */ - com.google.protobuf.ByteString - getStatementBytes(); - - // repeated .signal.SqlStatement.SqlParameter parameters = 2; - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - java.util.List<org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter> - getParametersList(); - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter getParameters(int index); - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - int getParametersCount(); - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - java.util.List<? extends org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameterOrBuilder> - getParametersOrBuilderList(); - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameterOrBuilder getParametersOrBuilder( - int index); - } - /** - * Protobuf type {@code signal.SqlStatement} - */ - public static final class SqlStatement extends - com.google.protobuf.GeneratedMessage - implements SqlStatementOrBuilder { - // Use SqlStatement.newBuilder() to construct. - private SqlStatement(com.google.protobuf.GeneratedMessage.Builder<?> builder) { - super(builder); - this.unknownFields = builder.getUnknownFields(); - } - private SqlStatement(boolean noInit) { this.unknownFields = com.google.protobuf.UnknownFieldSet.getDefaultInstance(); } - - private static final SqlStatement defaultInstance; - public static SqlStatement getDefaultInstance() { - return defaultInstance; - } - - public SqlStatement getDefaultInstanceForType() { - return defaultInstance; - } - - private final com.google.protobuf.UnknownFieldSet unknownFields; - @java.lang.Override - public final com.google.protobuf.UnknownFieldSet - getUnknownFields() { - return this.unknownFields; - } - private SqlStatement( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - initFields(); - int mutable_bitField0_ = 0; - com.google.protobuf.UnknownFieldSet.Builder unknownFields = - com.google.protobuf.UnknownFieldSet.newBuilder(); - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - default: { - if (!parseUnknownField(input, unknownFields, - extensionRegistry, tag)) { - done = true; - } - break; - } - case 10: { - bitField0_ |= 0x00000001; - statement_ = input.readBytes(); - break; - } - case 18: { - if (!((mutable_bitField0_ & 0x00000002) == 0x00000002)) { - parameters_ = new java.util.ArrayList<org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter>(); - mutable_bitField0_ |= 0x00000002; - } - parameters_.add(input.readMessage(org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.PARSER, extensionRegistry)); - break; - } - } - } - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(this); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException( - e.getMessage()).setUnfinishedMessage(this); - } finally { - if (((mutable_bitField0_ & 0x00000002) == 0x00000002)) { - parameters_ = java.util.Collections.unmodifiableList(parameters_); - } - this.unknownFields = unknownFields.build(); - makeExtensionsImmutable(); - } - } - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SqlStatement_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SqlStatement_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.class, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.Builder.class); - } - - public static com.google.protobuf.Parser<SqlStatement> PARSER = - new com.google.protobuf.AbstractParser<SqlStatement>() { - public SqlStatement parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return new SqlStatement(input, extensionRegistry); - } - }; - - @java.lang.Override - public com.google.protobuf.Parser<SqlStatement> getParserForType() { - return PARSER; - } - - public interface SqlParameterOrBuilder - extends com.google.protobuf.MessageOrBuilder { - - // optional string stringParamter = 1; - /** - * <code>optional string stringParamter = 1;</code> - */ - boolean hasStringParamter(); - /** - * <code>optional string stringParamter = 1;</code> - */ - java.lang.String getStringParamter(); - /** - * <code>optional string stringParamter = 1;</code> - */ - com.google.protobuf.ByteString - getStringParamterBytes(); - - // optional uint64 integerParameter = 2; - /** - * <code>optional uint64 integerParameter = 2;</code> - */ - boolean hasIntegerParameter(); - /** - * <code>optional uint64 integerParameter = 2;</code> - */ - long getIntegerParameter(); - - // optional double doubleParameter = 3; - /** - * <code>optional double doubleParameter = 3;</code> - */ - boolean hasDoubleParameter(); - /** - * <code>optional double doubleParameter = 3;</code> - */ - double getDoubleParameter(); - - // optional bytes blobParameter = 4; - /** - * <code>optional bytes blobParameter = 4;</code> - */ - boolean hasBlobParameter(); - /** - * <code>optional bytes blobParameter = 4;</code> - */ - com.google.protobuf.ByteString getBlobParameter(); - - // optional bool nullparameter = 5; - /** - * <code>optional bool nullparameter = 5;</code> - */ - boolean hasNullparameter(); - /** - * <code>optional bool nullparameter = 5;</code> - */ - boolean getNullparameter(); - } - /** - * Protobuf type {@code signal.SqlStatement.SqlParameter} - */ - public static final class SqlParameter extends - com.google.protobuf.GeneratedMessage - implements SqlParameterOrBuilder { - // Use SqlParameter.newBuilder() to construct. - private SqlParameter(com.google.protobuf.GeneratedMessage.Builder<?> builder) { - super(builder); - this.unknownFields = builder.getUnknownFields(); - } - private SqlParameter(boolean noInit) { this.unknownFields = com.google.protobuf.UnknownFieldSet.getDefaultInstance(); } - - private static final SqlParameter defaultInstance; - public static SqlParameter getDefaultInstance() { - return defaultInstance; - } - - public SqlParameter getDefaultInstanceForType() { - return defaultInstance; - } - - private final com.google.protobuf.UnknownFieldSet unknownFields; - @java.lang.Override - public final com.google.protobuf.UnknownFieldSet - getUnknownFields() { - return this.unknownFields; - } - private SqlParameter( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - initFields(); - int mutable_bitField0_ = 0; - com.google.protobuf.UnknownFieldSet.Builder unknownFields = - com.google.protobuf.UnknownFieldSet.newBuilder(); - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - default: { - if (!parseUnknownField(input, unknownFields, - extensionRegistry, tag)) { - done = true; - } - break; - } - case 10: { - bitField0_ |= 0x00000001; - stringParamter_ = input.readBytes(); - break; - } - case 16: { - bitField0_ |= 0x00000002; - integerParameter_ = input.readUInt64(); - break; - } - case 25: { - bitField0_ |= 0x00000004; - doubleParameter_ = input.readDouble(); - break; - } - case 34: { - bitField0_ |= 0x00000008; - blobParameter_ = input.readBytes(); - break; - } - case 40: { - bitField0_ |= 0x00000010; - nullparameter_ = input.readBool(); - break; - } - } - } - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(this); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException( - e.getMessage()).setUnfinishedMessage(this); - } finally { - this.unknownFields = unknownFields.build(); - makeExtensionsImmutable(); - } - } - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SqlStatement_SqlParameter_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SqlStatement_SqlParameter_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.class, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.Builder.class); - } - - public static com.google.protobuf.Parser<SqlParameter> PARSER = - new com.google.protobuf.AbstractParser<SqlParameter>() { - public SqlParameter parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return new SqlParameter(input, extensionRegistry); - } - }; - - @java.lang.Override - public com.google.protobuf.Parser<SqlParameter> getParserForType() { - return PARSER; - } - - private int bitField0_; - // optional string stringParamter = 1; - public static final int STRINGPARAMTER_FIELD_NUMBER = 1; - private java.lang.Object stringParamter_; - /** - * <code>optional string stringParamter = 1;</code> - */ - public boolean hasStringParamter() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional string stringParamter = 1;</code> - */ - public java.lang.String getStringParamter() { - java.lang.Object ref = stringParamter_; - if (ref instanceof java.lang.String) { - return (java.lang.String) ref; - } else { - com.google.protobuf.ByteString bs = - (com.google.protobuf.ByteString) ref; - java.lang.String s = bs.toStringUtf8(); - if (bs.isValidUtf8()) { - stringParamter_ = s; - } - return s; - } - } - /** - * <code>optional string stringParamter = 1;</code> - */ - public com.google.protobuf.ByteString - getStringParamterBytes() { - java.lang.Object ref = stringParamter_; - if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = - com.google.protobuf.ByteString.copyFromUtf8( - (java.lang.String) ref); - stringParamter_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - - // optional uint64 integerParameter = 2; - public static final int INTEGERPARAMETER_FIELD_NUMBER = 2; - private long integerParameter_; - /** - * <code>optional uint64 integerParameter = 2;</code> - */ - public boolean hasIntegerParameter() { - return ((bitField0_ & 0x00000002) == 0x00000002); - } - /** - * <code>optional uint64 integerParameter = 2;</code> - */ - public long getIntegerParameter() { - return integerParameter_; - } - - // optional double doubleParameter = 3; - public static final int DOUBLEPARAMETER_FIELD_NUMBER = 3; - private double doubleParameter_; - /** - * <code>optional double doubleParameter = 3;</code> - */ - public boolean hasDoubleParameter() { - return ((bitField0_ & 0x00000004) == 0x00000004); - } - /** - * <code>optional double doubleParameter = 3;</code> - */ - public double getDoubleParameter() { - return doubleParameter_; - } - - // optional bytes blobParameter = 4; - public static final int BLOBPARAMETER_FIELD_NUMBER = 4; - private com.google.protobuf.ByteString blobParameter_; - /** - * <code>optional bytes blobParameter = 4;</code> - */ - public boolean hasBlobParameter() { - return ((bitField0_ & 0x00000008) == 0x00000008); - } - /** - * <code>optional bytes blobParameter = 4;</code> - */ - public com.google.protobuf.ByteString getBlobParameter() { - return blobParameter_; - } - - // optional bool nullparameter = 5; - public static final int NULLPARAMETER_FIELD_NUMBER = 5; - private boolean nullparameter_; - /** - * <code>optional bool nullparameter = 5;</code> - */ - public boolean hasNullparameter() { - return ((bitField0_ & 0x00000010) == 0x00000010); - } - /** - * <code>optional bool nullparameter = 5;</code> - */ - public boolean getNullparameter() { - return nullparameter_; - } - - private void initFields() { - stringParamter_ = ""; - integerParameter_ = 0L; - doubleParameter_ = 0D; - blobParameter_ = com.google.protobuf.ByteString.EMPTY; - nullparameter_ = false; - } - private byte memoizedIsInitialized = -1; - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized != -1) return isInitialized == 1; - - memoizedIsInitialized = 1; - return true; - } - - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - getSerializedSize(); - if (((bitField0_ & 0x00000001) == 0x00000001)) { - output.writeBytes(1, getStringParamterBytes()); - } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - output.writeUInt64(2, integerParameter_); - } - if (((bitField0_ & 0x00000004) == 0x00000004)) { - output.writeDouble(3, doubleParameter_); - } - if (((bitField0_ & 0x00000008) == 0x00000008)) { - output.writeBytes(4, blobParameter_); - } - if (((bitField0_ & 0x00000010) == 0x00000010)) { - output.writeBool(5, nullparameter_); - } - getUnknownFields().writeTo(output); - } - - private int memoizedSerializedSize = -1; - public int getSerializedSize() { - int size = memoizedSerializedSize; - if (size != -1) return size; - - size = 0; - if (((bitField0_ & 0x00000001) == 0x00000001)) { - size += com.google.protobuf.CodedOutputStream - .computeBytesSize(1, getStringParamterBytes()); - } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - size += com.google.protobuf.CodedOutputStream - .computeUInt64Size(2, integerParameter_); - } - if (((bitField0_ & 0x00000004) == 0x00000004)) { - size += com.google.protobuf.CodedOutputStream - .computeDoubleSize(3, doubleParameter_); - } - if (((bitField0_ & 0x00000008) == 0x00000008)) { - size += com.google.protobuf.CodedOutputStream - .computeBytesSize(4, blobParameter_); - } - if (((bitField0_ & 0x00000010) == 0x00000010)) { - size += com.google.protobuf.CodedOutputStream - .computeBoolSize(5, nullparameter_); - } - size += getUnknownFields().getSerializedSize(); - memoizedSerializedSize = size; - return size; - } - - private static final long serialVersionUID = 0L; - @java.lang.Override - protected java.lang.Object writeReplace() - throws java.io.ObjectStreamException { - return super.writeReplace(); - } - - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter parseFrom( - com.google.protobuf.ByteString data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter parseFrom(byte[] data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter parseFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter parseDelimitedFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter parseFrom( - com.google.protobuf.CodedInputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - - public static Builder newBuilder() { return Builder.create(); } - public Builder newBuilderForType() { return newBuilder(); } - public static Builder newBuilder(org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter prototype) { - return newBuilder().mergeFrom(prototype); - } - public Builder toBuilder() { return newBuilder(this); } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - Builder builder = new Builder(parent); - return builder; - } - /** - * Protobuf type {@code signal.SqlStatement.SqlParameter} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder<Builder> - implements org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameterOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SqlStatement_SqlParameter_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SqlStatement_SqlParameter_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.class, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.Builder.class); - } - - // Construct using org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.newBuilder() - private Builder() { - maybeForceBuilderInitialization(); - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - super(parent); - maybeForceBuilderInitialization(); - } - private void maybeForceBuilderInitialization() { - if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { - } - } - private static Builder create() { - return new Builder(); - } - - public Builder clear() { - super.clear(); - stringParamter_ = ""; - bitField0_ = (bitField0_ & ~0x00000001); - integerParameter_ = 0L; - bitField0_ = (bitField0_ & ~0x00000002); - doubleParameter_ = 0D; - bitField0_ = (bitField0_ & ~0x00000004); - blobParameter_ = com.google.protobuf.ByteString.EMPTY; - bitField0_ = (bitField0_ & ~0x00000008); - nullparameter_ = false; - bitField0_ = (bitField0_ & ~0x00000010); - return this; - } - - public Builder clone() { - return create().mergeFrom(buildPartial()); - } - - public com.google.protobuf.Descriptors.Descriptor - getDescriptorForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SqlStatement_SqlParameter_descriptor; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter getDefaultInstanceForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.getDefaultInstance(); - } - - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter build() { - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter buildPartial() { - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter result = new org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter(this); - int from_bitField0_ = bitField0_; - int to_bitField0_ = 0; - if (((from_bitField0_ & 0x00000001) == 0x00000001)) { - to_bitField0_ |= 0x00000001; - } - result.stringParamter_ = stringParamter_; - if (((from_bitField0_ & 0x00000002) == 0x00000002)) { - to_bitField0_ |= 0x00000002; - } - result.integerParameter_ = integerParameter_; - if (((from_bitField0_ & 0x00000004) == 0x00000004)) { - to_bitField0_ |= 0x00000004; - } - result.doubleParameter_ = doubleParameter_; - if (((from_bitField0_ & 0x00000008) == 0x00000008)) { - to_bitField0_ |= 0x00000008; - } - result.blobParameter_ = blobParameter_; - if (((from_bitField0_ & 0x00000010) == 0x00000010)) { - to_bitField0_ |= 0x00000010; - } - result.nullparameter_ = nullparameter_; - result.bitField0_ = to_bitField0_; - onBuilt(); - return result; - } - - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter) { - return mergeFrom((org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter)other); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom(org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter other) { - if (other == org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.getDefaultInstance()) return this; - if (other.hasStringParamter()) { - bitField0_ |= 0x00000001; - stringParamter_ = other.stringParamter_; - onChanged(); - } - if (other.hasIntegerParameter()) { - setIntegerParameter(other.getIntegerParameter()); - } - if (other.hasDoubleParameter()) { - setDoubleParameter(other.getDoubleParameter()); - } - if (other.hasBlobParameter()) { - setBlobParameter(other.getBlobParameter()); - } - if (other.hasNullparameter()) { - setNullparameter(other.getNullparameter()); - } - this.mergeUnknownFields(other.getUnknownFields()); - return this; - } - - public final boolean isInitialized() { - return true; - } - - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter parsedMessage = null; - try { - parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - parsedMessage = (org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter) e.getUnfinishedMessage(); - throw e; - } finally { - if (parsedMessage != null) { - mergeFrom(parsedMessage); - } - } - return this; - } - private int bitField0_; - - // optional string stringParamter = 1; - private java.lang.Object stringParamter_ = ""; - /** - * <code>optional string stringParamter = 1;</code> - */ - public boolean hasStringParamter() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional string stringParamter = 1;</code> - */ - public java.lang.String getStringParamter() { - java.lang.Object ref = stringParamter_; - if (!(ref instanceof java.lang.String)) { - java.lang.String s = ((com.google.protobuf.ByteString) ref) - .toStringUtf8(); - stringParamter_ = s; - return s; - } else { - return (java.lang.String) ref; - } - } - /** - * <code>optional string stringParamter = 1;</code> - */ - public com.google.protobuf.ByteString - getStringParamterBytes() { - java.lang.Object ref = stringParamter_; - if (ref instanceof String) { - com.google.protobuf.ByteString b = - com.google.protobuf.ByteString.copyFromUtf8( - (java.lang.String) ref); - stringParamter_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - /** - * <code>optional string stringParamter = 1;</code> - */ - public Builder setStringParamter( - java.lang.String value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000001; - stringParamter_ = value; - onChanged(); - return this; - } - /** - * <code>optional string stringParamter = 1;</code> - */ - public Builder clearStringParamter() { - bitField0_ = (bitField0_ & ~0x00000001); - stringParamter_ = getDefaultInstance().getStringParamter(); - onChanged(); - return this; - } - /** - * <code>optional string stringParamter = 1;</code> - */ - public Builder setStringParamterBytes( - com.google.protobuf.ByteString value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000001; - stringParamter_ = value; - onChanged(); - return this; - } - - // optional uint64 integerParameter = 2; - private long integerParameter_ ; - /** - * <code>optional uint64 integerParameter = 2;</code> - */ - public boolean hasIntegerParameter() { - return ((bitField0_ & 0x00000002) == 0x00000002); - } - /** - * <code>optional uint64 integerParameter = 2;</code> - */ - public long getIntegerParameter() { - return integerParameter_; - } - /** - * <code>optional uint64 integerParameter = 2;</code> - */ - public Builder setIntegerParameter(long value) { - bitField0_ |= 0x00000002; - integerParameter_ = value; - onChanged(); - return this; - } - /** - * <code>optional uint64 integerParameter = 2;</code> - */ - public Builder clearIntegerParameter() { - bitField0_ = (bitField0_ & ~0x00000002); - integerParameter_ = 0L; - onChanged(); - return this; - } - - // optional double doubleParameter = 3; - private double doubleParameter_ ; - /** - * <code>optional double doubleParameter = 3;</code> - */ - public boolean hasDoubleParameter() { - return ((bitField0_ & 0x00000004) == 0x00000004); - } - /** - * <code>optional double doubleParameter = 3;</code> - */ - public double getDoubleParameter() { - return doubleParameter_; - } - /** - * <code>optional double doubleParameter = 3;</code> - */ - public Builder setDoubleParameter(double value) { - bitField0_ |= 0x00000004; - doubleParameter_ = value; - onChanged(); - return this; - } - /** - * <code>optional double doubleParameter = 3;</code> - */ - public Builder clearDoubleParameter() { - bitField0_ = (bitField0_ & ~0x00000004); - doubleParameter_ = 0D; - onChanged(); - return this; - } - - // optional bytes blobParameter = 4; - private com.google.protobuf.ByteString blobParameter_ = com.google.protobuf.ByteString.EMPTY; - /** - * <code>optional bytes blobParameter = 4;</code> - */ - public boolean hasBlobParameter() { - return ((bitField0_ & 0x00000008) == 0x00000008); - } - /** - * <code>optional bytes blobParameter = 4;</code> - */ - public com.google.protobuf.ByteString getBlobParameter() { - return blobParameter_; - } - /** - * <code>optional bytes blobParameter = 4;</code> - */ - public Builder setBlobParameter(com.google.protobuf.ByteString value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000008; - blobParameter_ = value; - onChanged(); - return this; - } - /** - * <code>optional bytes blobParameter = 4;</code> - */ - public Builder clearBlobParameter() { - bitField0_ = (bitField0_ & ~0x00000008); - blobParameter_ = getDefaultInstance().getBlobParameter(); - onChanged(); - return this; - } - - // optional bool nullparameter = 5; - private boolean nullparameter_ ; - /** - * <code>optional bool nullparameter = 5;</code> - */ - public boolean hasNullparameter() { - return ((bitField0_ & 0x00000010) == 0x00000010); - } - /** - * <code>optional bool nullparameter = 5;</code> - */ - public boolean getNullparameter() { - return nullparameter_; - } - /** - * <code>optional bool nullparameter = 5;</code> - */ - public Builder setNullparameter(boolean value) { - bitField0_ |= 0x00000010; - nullparameter_ = value; - onChanged(); - return this; - } - /** - * <code>optional bool nullparameter = 5;</code> - */ - public Builder clearNullparameter() { - bitField0_ = (bitField0_ & ~0x00000010); - nullparameter_ = false; - onChanged(); - return this; - } - - // @@protoc_insertion_point(builder_scope:signal.SqlStatement.SqlParameter) - } - - static { - defaultInstance = new SqlParameter(true); - defaultInstance.initFields(); - } - - // @@protoc_insertion_point(class_scope:signal.SqlStatement.SqlParameter) - } - - private int bitField0_; - // optional string statement = 1; - public static final int STATEMENT_FIELD_NUMBER = 1; - private java.lang.Object statement_; - /** - * <code>optional string statement = 1;</code> - */ - public boolean hasStatement() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional string statement = 1;</code> - */ - public java.lang.String getStatement() { - java.lang.Object ref = statement_; - if (ref instanceof java.lang.String) { - return (java.lang.String) ref; - } else { - com.google.protobuf.ByteString bs = - (com.google.protobuf.ByteString) ref; - java.lang.String s = bs.toStringUtf8(); - if (bs.isValidUtf8()) { - statement_ = s; - } - return s; - } - } - /** - * <code>optional string statement = 1;</code> - */ - public com.google.protobuf.ByteString - getStatementBytes() { - java.lang.Object ref = statement_; - if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = - com.google.protobuf.ByteString.copyFromUtf8( - (java.lang.String) ref); - statement_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - - // repeated .signal.SqlStatement.SqlParameter parameters = 2; - public static final int PARAMETERS_FIELD_NUMBER = 2; - private java.util.List<org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter> parameters_; - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public java.util.List<org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter> getParametersList() { - return parameters_; - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public java.util.List<? extends org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameterOrBuilder> - getParametersOrBuilderList() { - return parameters_; - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public int getParametersCount() { - return parameters_.size(); - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter getParameters(int index) { - return parameters_.get(index); - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameterOrBuilder getParametersOrBuilder( - int index) { - return parameters_.get(index); - } - - private void initFields() { - statement_ = ""; - parameters_ = java.util.Collections.emptyList(); - } - private byte memoizedIsInitialized = -1; - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized != -1) return isInitialized == 1; - - memoizedIsInitialized = 1; - return true; - } - - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - getSerializedSize(); - if (((bitField0_ & 0x00000001) == 0x00000001)) { - output.writeBytes(1, getStatementBytes()); - } - for (int i = 0; i < parameters_.size(); i++) { - output.writeMessage(2, parameters_.get(i)); - } - getUnknownFields().writeTo(output); - } - - private int memoizedSerializedSize = -1; - public int getSerializedSize() { - int size = memoizedSerializedSize; - if (size != -1) return size; - - size = 0; - if (((bitField0_ & 0x00000001) == 0x00000001)) { - size += com.google.protobuf.CodedOutputStream - .computeBytesSize(1, getStatementBytes()); - } - for (int i = 0; i < parameters_.size(); i++) { - size += com.google.protobuf.CodedOutputStream - .computeMessageSize(2, parameters_.get(i)); - } - size += getUnknownFields().getSerializedSize(); - memoizedSerializedSize = size; - return size; - } - - private static final long serialVersionUID = 0L; - @java.lang.Override - protected java.lang.Object writeReplace() - throws java.io.ObjectStreamException { - return super.writeReplace(); - } - - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement parseFrom( - com.google.protobuf.ByteString data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement parseFrom(byte[] data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement parseFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement parseDelimitedFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement parseFrom( - com.google.protobuf.CodedInputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - - public static Builder newBuilder() { return Builder.create(); } - public Builder newBuilderForType() { return newBuilder(); } - public static Builder newBuilder(org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement prototype) { - return newBuilder().mergeFrom(prototype); - } - public Builder toBuilder() { return newBuilder(this); } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - Builder builder = new Builder(parent); - return builder; - } - /** - * Protobuf type {@code signal.SqlStatement} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder<Builder> - implements org.thoughtcrime.securesms.backup.BackupProtos.SqlStatementOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SqlStatement_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SqlStatement_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.class, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.Builder.class); - } - - // Construct using org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.newBuilder() - private Builder() { - maybeForceBuilderInitialization(); - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - super(parent); - maybeForceBuilderInitialization(); - } - private void maybeForceBuilderInitialization() { - if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { - getParametersFieldBuilder(); - } - } - private static Builder create() { - return new Builder(); - } - - public Builder clear() { - super.clear(); - statement_ = ""; - bitField0_ = (bitField0_ & ~0x00000001); - if (parametersBuilder_ == null) { - parameters_ = java.util.Collections.emptyList(); - bitField0_ = (bitField0_ & ~0x00000002); - } else { - parametersBuilder_.clear(); - } - return this; - } - - public Builder clone() { - return create().mergeFrom(buildPartial()); - } - - public com.google.protobuf.Descriptors.Descriptor - getDescriptorForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SqlStatement_descriptor; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement getDefaultInstanceForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.getDefaultInstance(); - } - - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement build() { - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement buildPartial() { - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement result = new org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement(this); - int from_bitField0_ = bitField0_; - int to_bitField0_ = 0; - if (((from_bitField0_ & 0x00000001) == 0x00000001)) { - to_bitField0_ |= 0x00000001; - } - result.statement_ = statement_; - if (parametersBuilder_ == null) { - if (((bitField0_ & 0x00000002) == 0x00000002)) { - parameters_ = java.util.Collections.unmodifiableList(parameters_); - bitField0_ = (bitField0_ & ~0x00000002); - } - result.parameters_ = parameters_; - } else { - result.parameters_ = parametersBuilder_.build(); - } - result.bitField0_ = to_bitField0_; - onBuilt(); - return result; - } - - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement) { - return mergeFrom((org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement)other); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom(org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement other) { - if (other == org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.getDefaultInstance()) return this; - if (other.hasStatement()) { - bitField0_ |= 0x00000001; - statement_ = other.statement_; - onChanged(); - } - if (parametersBuilder_ == null) { - if (!other.parameters_.isEmpty()) { - if (parameters_.isEmpty()) { - parameters_ = other.parameters_; - bitField0_ = (bitField0_ & ~0x00000002); - } else { - ensureParametersIsMutable(); - parameters_.addAll(other.parameters_); - } - onChanged(); - } - } else { - if (!other.parameters_.isEmpty()) { - if (parametersBuilder_.isEmpty()) { - parametersBuilder_.dispose(); - parametersBuilder_ = null; - parameters_ = other.parameters_; - bitField0_ = (bitField0_ & ~0x00000002); - parametersBuilder_ = - com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ? - getParametersFieldBuilder() : null; - } else { - parametersBuilder_.addAllMessages(other.parameters_); - } - } - } - this.mergeUnknownFields(other.getUnknownFields()); - return this; - } - - public final boolean isInitialized() { - return true; - } - - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement parsedMessage = null; - try { - parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - parsedMessage = (org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement) e.getUnfinishedMessage(); - throw e; - } finally { - if (parsedMessage != null) { - mergeFrom(parsedMessage); - } - } - return this; - } - private int bitField0_; - - // optional string statement = 1; - private java.lang.Object statement_ = ""; - /** - * <code>optional string statement = 1;</code> - */ - public boolean hasStatement() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional string statement = 1;</code> - */ - public java.lang.String getStatement() { - java.lang.Object ref = statement_; - if (!(ref instanceof java.lang.String)) { - java.lang.String s = ((com.google.protobuf.ByteString) ref) - .toStringUtf8(); - statement_ = s; - return s; - } else { - return (java.lang.String) ref; - } - } - /** - * <code>optional string statement = 1;</code> - */ - public com.google.protobuf.ByteString - getStatementBytes() { - java.lang.Object ref = statement_; - if (ref instanceof String) { - com.google.protobuf.ByteString b = - com.google.protobuf.ByteString.copyFromUtf8( - (java.lang.String) ref); - statement_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - /** - * <code>optional string statement = 1;</code> - */ - public Builder setStatement( - java.lang.String value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000001; - statement_ = value; - onChanged(); - return this; - } - /** - * <code>optional string statement = 1;</code> - */ - public Builder clearStatement() { - bitField0_ = (bitField0_ & ~0x00000001); - statement_ = getDefaultInstance().getStatement(); - onChanged(); - return this; - } - /** - * <code>optional string statement = 1;</code> - */ - public Builder setStatementBytes( - com.google.protobuf.ByteString value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000001; - statement_ = value; - onChanged(); - return this; - } - - // repeated .signal.SqlStatement.SqlParameter parameters = 2; - private java.util.List<org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter> parameters_ = - java.util.Collections.emptyList(); - private void ensureParametersIsMutable() { - if (!((bitField0_ & 0x00000002) == 0x00000002)) { - parameters_ = new java.util.ArrayList<org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter>(parameters_); - bitField0_ |= 0x00000002; - } - } - - private com.google.protobuf.RepeatedFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.Builder, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameterOrBuilder> parametersBuilder_; - - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public java.util.List<org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter> getParametersList() { - if (parametersBuilder_ == null) { - return java.util.Collections.unmodifiableList(parameters_); - } else { - return parametersBuilder_.getMessageList(); - } - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public int getParametersCount() { - if (parametersBuilder_ == null) { - return parameters_.size(); - } else { - return parametersBuilder_.getCount(); - } - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter getParameters(int index) { - if (parametersBuilder_ == null) { - return parameters_.get(index); - } else { - return parametersBuilder_.getMessage(index); - } - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public Builder setParameters( - int index, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter value) { - if (parametersBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - ensureParametersIsMutable(); - parameters_.set(index, value); - onChanged(); - } else { - parametersBuilder_.setMessage(index, value); - } - return this; - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public Builder setParameters( - int index, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.Builder builderForValue) { - if (parametersBuilder_ == null) { - ensureParametersIsMutable(); - parameters_.set(index, builderForValue.build()); - onChanged(); - } else { - parametersBuilder_.setMessage(index, builderForValue.build()); - } - return this; - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public Builder addParameters(org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter value) { - if (parametersBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - ensureParametersIsMutable(); - parameters_.add(value); - onChanged(); - } else { - parametersBuilder_.addMessage(value); - } - return this; - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public Builder addParameters( - int index, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter value) { - if (parametersBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - ensureParametersIsMutable(); - parameters_.add(index, value); - onChanged(); - } else { - parametersBuilder_.addMessage(index, value); - } - return this; - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public Builder addParameters( - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.Builder builderForValue) { - if (parametersBuilder_ == null) { - ensureParametersIsMutable(); - parameters_.add(builderForValue.build()); - onChanged(); - } else { - parametersBuilder_.addMessage(builderForValue.build()); - } - return this; - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public Builder addParameters( - int index, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.Builder builderForValue) { - if (parametersBuilder_ == null) { - ensureParametersIsMutable(); - parameters_.add(index, builderForValue.build()); - onChanged(); - } else { - parametersBuilder_.addMessage(index, builderForValue.build()); - } - return this; - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public Builder addAllParameters( - java.lang.Iterable<? extends org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter> values) { - if (parametersBuilder_ == null) { - ensureParametersIsMutable(); - super.addAll(values, parameters_); - onChanged(); - } else { - parametersBuilder_.addAllMessages(values); - } - return this; - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public Builder clearParameters() { - if (parametersBuilder_ == null) { - parameters_ = java.util.Collections.emptyList(); - bitField0_ = (bitField0_ & ~0x00000002); - onChanged(); - } else { - parametersBuilder_.clear(); - } - return this; - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public Builder removeParameters(int index) { - if (parametersBuilder_ == null) { - ensureParametersIsMutable(); - parameters_.remove(index); - onChanged(); - } else { - parametersBuilder_.remove(index); - } - return this; - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.Builder getParametersBuilder( - int index) { - return getParametersFieldBuilder().getBuilder(index); - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameterOrBuilder getParametersOrBuilder( - int index) { - if (parametersBuilder_ == null) { - return parameters_.get(index); } else { - return parametersBuilder_.getMessageOrBuilder(index); - } - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public java.util.List<? extends org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameterOrBuilder> - getParametersOrBuilderList() { - if (parametersBuilder_ != null) { - return parametersBuilder_.getMessageOrBuilderList(); - } else { - return java.util.Collections.unmodifiableList(parameters_); - } - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.Builder addParametersBuilder() { - return getParametersFieldBuilder().addBuilder( - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.getDefaultInstance()); - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.Builder addParametersBuilder( - int index) { - return getParametersFieldBuilder().addBuilder( - index, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.getDefaultInstance()); - } - /** - * <code>repeated .signal.SqlStatement.SqlParameter parameters = 2;</code> - */ - public java.util.List<org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.Builder> - getParametersBuilderList() { - return getParametersFieldBuilder().getBuilderList(); - } - private com.google.protobuf.RepeatedFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.Builder, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameterOrBuilder> - getParametersFieldBuilder() { - if (parametersBuilder_ == null) { - parametersBuilder_ = new com.google.protobuf.RepeatedFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameter.Builder, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.SqlParameterOrBuilder>( - parameters_, - ((bitField0_ & 0x00000002) == 0x00000002), - getParentForChildren(), - isClean()); - parameters_ = null; - } - return parametersBuilder_; - } - - // @@protoc_insertion_point(builder_scope:signal.SqlStatement) - } - - static { - defaultInstance = new SqlStatement(true); - defaultInstance.initFields(); - } - - // @@protoc_insertion_point(class_scope:signal.SqlStatement) - } - - public interface SharedPreferenceOrBuilder - extends com.google.protobuf.MessageOrBuilder { - - // optional string file = 1; - /** - * <code>optional string file = 1;</code> - */ - boolean hasFile(); - /** - * <code>optional string file = 1;</code> - */ - java.lang.String getFile(); - /** - * <code>optional string file = 1;</code> - */ - com.google.protobuf.ByteString - getFileBytes(); - - // optional string key = 2; - /** - * <code>optional string key = 2;</code> - */ - boolean hasKey(); - /** - * <code>optional string key = 2;</code> - */ - java.lang.String getKey(); - /** - * <code>optional string key = 2;</code> - */ - com.google.protobuf.ByteString - getKeyBytes(); - - // optional string value = 3; - /** - * <code>optional string value = 3;</code> - */ - boolean hasValue(); - /** - * <code>optional string value = 3;</code> - */ - java.lang.String getValue(); - /** - * <code>optional string value = 3;</code> - */ - com.google.protobuf.ByteString - getValueBytes(); - } - /** - * Protobuf type {@code signal.SharedPreference} - */ - public static final class SharedPreference extends - com.google.protobuf.GeneratedMessage - implements SharedPreferenceOrBuilder { - // Use SharedPreference.newBuilder() to construct. - private SharedPreference(com.google.protobuf.GeneratedMessage.Builder<?> builder) { - super(builder); - this.unknownFields = builder.getUnknownFields(); - } - private SharedPreference(boolean noInit) { this.unknownFields = com.google.protobuf.UnknownFieldSet.getDefaultInstance(); } - - private static final SharedPreference defaultInstance; - public static SharedPreference getDefaultInstance() { - return defaultInstance; - } - - public SharedPreference getDefaultInstanceForType() { - return defaultInstance; - } - - private final com.google.protobuf.UnknownFieldSet unknownFields; - @java.lang.Override - public final com.google.protobuf.UnknownFieldSet - getUnknownFields() { - return this.unknownFields; - } - private SharedPreference( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - initFields(); - int mutable_bitField0_ = 0; - com.google.protobuf.UnknownFieldSet.Builder unknownFields = - com.google.protobuf.UnknownFieldSet.newBuilder(); - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - default: { - if (!parseUnknownField(input, unknownFields, - extensionRegistry, tag)) { - done = true; - } - break; - } - case 10: { - bitField0_ |= 0x00000001; - file_ = input.readBytes(); - break; - } - case 18: { - bitField0_ |= 0x00000002; - key_ = input.readBytes(); - break; - } - case 26: { - bitField0_ |= 0x00000004; - value_ = input.readBytes(); - break; - } - } - } - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(this); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException( - e.getMessage()).setUnfinishedMessage(this); - } finally { - this.unknownFields = unknownFields.build(); - makeExtensionsImmutable(); - } - } - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SharedPreference_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SharedPreference_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.class, org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.Builder.class); - } - - public static com.google.protobuf.Parser<SharedPreference> PARSER = - new com.google.protobuf.AbstractParser<SharedPreference>() { - public SharedPreference parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return new SharedPreference(input, extensionRegistry); - } - }; - - @java.lang.Override - public com.google.protobuf.Parser<SharedPreference> getParserForType() { - return PARSER; - } - - private int bitField0_; - // optional string file = 1; - public static final int FILE_FIELD_NUMBER = 1; - private java.lang.Object file_; - /** - * <code>optional string file = 1;</code> - */ - public boolean hasFile() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional string file = 1;</code> - */ - public java.lang.String getFile() { - java.lang.Object ref = file_; - if (ref instanceof java.lang.String) { - return (java.lang.String) ref; - } else { - com.google.protobuf.ByteString bs = - (com.google.protobuf.ByteString) ref; - java.lang.String s = bs.toStringUtf8(); - if (bs.isValidUtf8()) { - file_ = s; - } - return s; - } - } - /** - * <code>optional string file = 1;</code> - */ - public com.google.protobuf.ByteString - getFileBytes() { - java.lang.Object ref = file_; - if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = - com.google.protobuf.ByteString.copyFromUtf8( - (java.lang.String) ref); - file_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - - // optional string key = 2; - public static final int KEY_FIELD_NUMBER = 2; - private java.lang.Object key_; - /** - * <code>optional string key = 2;</code> - */ - public boolean hasKey() { - return ((bitField0_ & 0x00000002) == 0x00000002); - } - /** - * <code>optional string key = 2;</code> - */ - public java.lang.String getKey() { - java.lang.Object ref = key_; - if (ref instanceof java.lang.String) { - return (java.lang.String) ref; - } else { - com.google.protobuf.ByteString bs = - (com.google.protobuf.ByteString) ref; - java.lang.String s = bs.toStringUtf8(); - if (bs.isValidUtf8()) { - key_ = s; - } - return s; - } - } - /** - * <code>optional string key = 2;</code> - */ - public com.google.protobuf.ByteString - getKeyBytes() { - java.lang.Object ref = key_; - if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = - com.google.protobuf.ByteString.copyFromUtf8( - (java.lang.String) ref); - key_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - - // optional string value = 3; - public static final int VALUE_FIELD_NUMBER = 3; - private java.lang.Object value_; - /** - * <code>optional string value = 3;</code> - */ - public boolean hasValue() { - return ((bitField0_ & 0x00000004) == 0x00000004); - } - /** - * <code>optional string value = 3;</code> - */ - public java.lang.String getValue() { - java.lang.Object ref = value_; - if (ref instanceof java.lang.String) { - return (java.lang.String) ref; - } else { - com.google.protobuf.ByteString bs = - (com.google.protobuf.ByteString) ref; - java.lang.String s = bs.toStringUtf8(); - if (bs.isValidUtf8()) { - value_ = s; - } - return s; - } - } - /** - * <code>optional string value = 3;</code> - */ - public com.google.protobuf.ByteString - getValueBytes() { - java.lang.Object ref = value_; - if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = - com.google.protobuf.ByteString.copyFromUtf8( - (java.lang.String) ref); - value_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - - private void initFields() { - file_ = ""; - key_ = ""; - value_ = ""; - } - private byte memoizedIsInitialized = -1; - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized != -1) return isInitialized == 1; - - memoizedIsInitialized = 1; - return true; - } - - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - getSerializedSize(); - if (((bitField0_ & 0x00000001) == 0x00000001)) { - output.writeBytes(1, getFileBytes()); - } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - output.writeBytes(2, getKeyBytes()); - } - if (((bitField0_ & 0x00000004) == 0x00000004)) { - output.writeBytes(3, getValueBytes()); - } - getUnknownFields().writeTo(output); - } - - private int memoizedSerializedSize = -1; - public int getSerializedSize() { - int size = memoizedSerializedSize; - if (size != -1) return size; - - size = 0; - if (((bitField0_ & 0x00000001) == 0x00000001)) { - size += com.google.protobuf.CodedOutputStream - .computeBytesSize(1, getFileBytes()); - } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - size += com.google.protobuf.CodedOutputStream - .computeBytesSize(2, getKeyBytes()); - } - if (((bitField0_ & 0x00000004) == 0x00000004)) { - size += com.google.protobuf.CodedOutputStream - .computeBytesSize(3, getValueBytes()); - } - size += getUnknownFields().getSerializedSize(); - memoizedSerializedSize = size; - return size; - } - - private static final long serialVersionUID = 0L; - @java.lang.Override - protected java.lang.Object writeReplace() - throws java.io.ObjectStreamException { - return super.writeReplace(); - } - - public static org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference parseFrom( - com.google.protobuf.ByteString data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference parseFrom(byte[] data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference parseFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference parseDelimitedFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference parseFrom( - com.google.protobuf.CodedInputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - - public static Builder newBuilder() { return Builder.create(); } - public Builder newBuilderForType() { return newBuilder(); } - public static Builder newBuilder(org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference prototype) { - return newBuilder().mergeFrom(prototype); - } - public Builder toBuilder() { return newBuilder(this); } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - Builder builder = new Builder(parent); - return builder; - } - /** - * Protobuf type {@code signal.SharedPreference} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder<Builder> - implements org.thoughtcrime.securesms.backup.BackupProtos.SharedPreferenceOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SharedPreference_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SharedPreference_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.class, org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.Builder.class); - } - - // Construct using org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.newBuilder() - private Builder() { - maybeForceBuilderInitialization(); - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - super(parent); - maybeForceBuilderInitialization(); - } - private void maybeForceBuilderInitialization() { - if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { - } - } - private static Builder create() { - return new Builder(); - } - - public Builder clear() { - super.clear(); - file_ = ""; - bitField0_ = (bitField0_ & ~0x00000001); - key_ = ""; - bitField0_ = (bitField0_ & ~0x00000002); - value_ = ""; - bitField0_ = (bitField0_ & ~0x00000004); - return this; - } - - public Builder clone() { - return create().mergeFrom(buildPartial()); - } - - public com.google.protobuf.Descriptors.Descriptor - getDescriptorForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_SharedPreference_descriptor; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference getDefaultInstanceForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.getDefaultInstance(); - } - - public org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference build() { - org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference buildPartial() { - org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference result = new org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference(this); - int from_bitField0_ = bitField0_; - int to_bitField0_ = 0; - if (((from_bitField0_ & 0x00000001) == 0x00000001)) { - to_bitField0_ |= 0x00000001; - } - result.file_ = file_; - if (((from_bitField0_ & 0x00000002) == 0x00000002)) { - to_bitField0_ |= 0x00000002; - } - result.key_ = key_; - if (((from_bitField0_ & 0x00000004) == 0x00000004)) { - to_bitField0_ |= 0x00000004; - } - result.value_ = value_; - result.bitField0_ = to_bitField0_; - onBuilt(); - return result; - } - - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference) { - return mergeFrom((org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference)other); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom(org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference other) { - if (other == org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.getDefaultInstance()) return this; - if (other.hasFile()) { - bitField0_ |= 0x00000001; - file_ = other.file_; - onChanged(); - } - if (other.hasKey()) { - bitField0_ |= 0x00000002; - key_ = other.key_; - onChanged(); - } - if (other.hasValue()) { - bitField0_ |= 0x00000004; - value_ = other.value_; - onChanged(); - } - this.mergeUnknownFields(other.getUnknownFields()); - return this; - } - - public final boolean isInitialized() { - return true; - } - - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference parsedMessage = null; - try { - parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - parsedMessage = (org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference) e.getUnfinishedMessage(); - throw e; - } finally { - if (parsedMessage != null) { - mergeFrom(parsedMessage); - } - } - return this; - } - private int bitField0_; - - // optional string file = 1; - private java.lang.Object file_ = ""; - /** - * <code>optional string file = 1;</code> - */ - public boolean hasFile() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional string file = 1;</code> - */ - public java.lang.String getFile() { - java.lang.Object ref = file_; - if (!(ref instanceof java.lang.String)) { - java.lang.String s = ((com.google.protobuf.ByteString) ref) - .toStringUtf8(); - file_ = s; - return s; - } else { - return (java.lang.String) ref; - } - } - /** - * <code>optional string file = 1;</code> - */ - public com.google.protobuf.ByteString - getFileBytes() { - java.lang.Object ref = file_; - if (ref instanceof String) { - com.google.protobuf.ByteString b = - com.google.protobuf.ByteString.copyFromUtf8( - (java.lang.String) ref); - file_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - /** - * <code>optional string file = 1;</code> - */ - public Builder setFile( - java.lang.String value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000001; - file_ = value; - onChanged(); - return this; - } - /** - * <code>optional string file = 1;</code> - */ - public Builder clearFile() { - bitField0_ = (bitField0_ & ~0x00000001); - file_ = getDefaultInstance().getFile(); - onChanged(); - return this; - } - /** - * <code>optional string file = 1;</code> - */ - public Builder setFileBytes( - com.google.protobuf.ByteString value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000001; - file_ = value; - onChanged(); - return this; - } - - // optional string key = 2; - private java.lang.Object key_ = ""; - /** - * <code>optional string key = 2;</code> - */ - public boolean hasKey() { - return ((bitField0_ & 0x00000002) == 0x00000002); - } - /** - * <code>optional string key = 2;</code> - */ - public java.lang.String getKey() { - java.lang.Object ref = key_; - if (!(ref instanceof java.lang.String)) { - java.lang.String s = ((com.google.protobuf.ByteString) ref) - .toStringUtf8(); - key_ = s; - return s; - } else { - return (java.lang.String) ref; - } - } - /** - * <code>optional string key = 2;</code> - */ - public com.google.protobuf.ByteString - getKeyBytes() { - java.lang.Object ref = key_; - if (ref instanceof String) { - com.google.protobuf.ByteString b = - com.google.protobuf.ByteString.copyFromUtf8( - (java.lang.String) ref); - key_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - /** - * <code>optional string key = 2;</code> - */ - public Builder setKey( - java.lang.String value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000002; - key_ = value; - onChanged(); - return this; - } - /** - * <code>optional string key = 2;</code> - */ - public Builder clearKey() { - bitField0_ = (bitField0_ & ~0x00000002); - key_ = getDefaultInstance().getKey(); - onChanged(); - return this; - } - /** - * <code>optional string key = 2;</code> - */ - public Builder setKeyBytes( - com.google.protobuf.ByteString value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000002; - key_ = value; - onChanged(); - return this; - } - - // optional string value = 3; - private java.lang.Object value_ = ""; - /** - * <code>optional string value = 3;</code> - */ - public boolean hasValue() { - return ((bitField0_ & 0x00000004) == 0x00000004); - } - /** - * <code>optional string value = 3;</code> - */ - public java.lang.String getValue() { - java.lang.Object ref = value_; - if (!(ref instanceof java.lang.String)) { - java.lang.String s = ((com.google.protobuf.ByteString) ref) - .toStringUtf8(); - value_ = s; - return s; - } else { - return (java.lang.String) ref; - } - } - /** - * <code>optional string value = 3;</code> - */ - public com.google.protobuf.ByteString - getValueBytes() { - java.lang.Object ref = value_; - if (ref instanceof String) { - com.google.protobuf.ByteString b = - com.google.protobuf.ByteString.copyFromUtf8( - (java.lang.String) ref); - value_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - /** - * <code>optional string value = 3;</code> - */ - public Builder setValue( - java.lang.String value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000004; - value_ = value; - onChanged(); - return this; - } - /** - * <code>optional string value = 3;</code> - */ - public Builder clearValue() { - bitField0_ = (bitField0_ & ~0x00000004); - value_ = getDefaultInstance().getValue(); - onChanged(); - return this; - } - /** - * <code>optional string value = 3;</code> - */ - public Builder setValueBytes( - com.google.protobuf.ByteString value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000004; - value_ = value; - onChanged(); - return this; - } - - // @@protoc_insertion_point(builder_scope:signal.SharedPreference) - } - - static { - defaultInstance = new SharedPreference(true); - defaultInstance.initFields(); - } - - // @@protoc_insertion_point(class_scope:signal.SharedPreference) - } - - public interface AttachmentOrBuilder - extends com.google.protobuf.MessageOrBuilder { - - // optional uint64 rowId = 1; - /** - * <code>optional uint64 rowId = 1;</code> - */ - boolean hasRowId(); - /** - * <code>optional uint64 rowId = 1;</code> - */ - long getRowId(); - - // optional uint64 attachmentId = 2; - /** - * <code>optional uint64 attachmentId = 2;</code> - */ - boolean hasAttachmentId(); - /** - * <code>optional uint64 attachmentId = 2;</code> - */ - long getAttachmentId(); - - // optional uint32 length = 3; - /** - * <code>optional uint32 length = 3;</code> - */ - boolean hasLength(); - /** - * <code>optional uint32 length = 3;</code> - */ - int getLength(); - } - /** - * Protobuf type {@code signal.Attachment} - */ - public static final class Attachment extends - com.google.protobuf.GeneratedMessage - implements AttachmentOrBuilder { - // Use Attachment.newBuilder() to construct. - private Attachment(com.google.protobuf.GeneratedMessage.Builder<?> builder) { - super(builder); - this.unknownFields = builder.getUnknownFields(); - } - private Attachment(boolean noInit) { this.unknownFields = com.google.protobuf.UnknownFieldSet.getDefaultInstance(); } - - private static final Attachment defaultInstance; - public static Attachment getDefaultInstance() { - return defaultInstance; - } - - public Attachment getDefaultInstanceForType() { - return defaultInstance; - } - - private final com.google.protobuf.UnknownFieldSet unknownFields; - @java.lang.Override - public final com.google.protobuf.UnknownFieldSet - getUnknownFields() { - return this.unknownFields; - } - private Attachment( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - initFields(); - int mutable_bitField0_ = 0; - com.google.protobuf.UnknownFieldSet.Builder unknownFields = - com.google.protobuf.UnknownFieldSet.newBuilder(); - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - default: { - if (!parseUnknownField(input, unknownFields, - extensionRegistry, tag)) { - done = true; - } - break; - } - case 8: { - bitField0_ |= 0x00000001; - rowId_ = input.readUInt64(); - break; - } - case 16: { - bitField0_ |= 0x00000002; - attachmentId_ = input.readUInt64(); - break; - } - case 24: { - bitField0_ |= 0x00000004; - length_ = input.readUInt32(); - break; - } - } - } - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(this); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException( - e.getMessage()).setUnfinishedMessage(this); - } finally { - this.unknownFields = unknownFields.build(); - makeExtensionsImmutable(); - } - } - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Attachment_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Attachment_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.Attachment.class, org.thoughtcrime.securesms.backup.BackupProtos.Attachment.Builder.class); - } - - public static com.google.protobuf.Parser<Attachment> PARSER = - new com.google.protobuf.AbstractParser<Attachment>() { - public Attachment parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return new Attachment(input, extensionRegistry); - } - }; - - @java.lang.Override - public com.google.protobuf.Parser<Attachment> getParserForType() { - return PARSER; - } - - private int bitField0_; - // optional uint64 rowId = 1; - public static final int ROWID_FIELD_NUMBER = 1; - private long rowId_; - /** - * <code>optional uint64 rowId = 1;</code> - */ - public boolean hasRowId() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional uint64 rowId = 1;</code> - */ - public long getRowId() { - return rowId_; - } - - // optional uint64 attachmentId = 2; - public static final int ATTACHMENTID_FIELD_NUMBER = 2; - private long attachmentId_; - /** - * <code>optional uint64 attachmentId = 2;</code> - */ - public boolean hasAttachmentId() { - return ((bitField0_ & 0x00000002) == 0x00000002); - } - /** - * <code>optional uint64 attachmentId = 2;</code> - */ - public long getAttachmentId() { - return attachmentId_; - } - - // optional uint32 length = 3; - public static final int LENGTH_FIELD_NUMBER = 3; - private int length_; - /** - * <code>optional uint32 length = 3;</code> - */ - public boolean hasLength() { - return ((bitField0_ & 0x00000004) == 0x00000004); - } - /** - * <code>optional uint32 length = 3;</code> - */ - public int getLength() { - return length_; - } - - private void initFields() { - rowId_ = 0L; - attachmentId_ = 0L; - length_ = 0; - } - private byte memoizedIsInitialized = -1; - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized != -1) return isInitialized == 1; - - memoizedIsInitialized = 1; - return true; - } - - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - getSerializedSize(); - if (((bitField0_ & 0x00000001) == 0x00000001)) { - output.writeUInt64(1, rowId_); - } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - output.writeUInt64(2, attachmentId_); - } - if (((bitField0_ & 0x00000004) == 0x00000004)) { - output.writeUInt32(3, length_); - } - getUnknownFields().writeTo(output); - } - - private int memoizedSerializedSize = -1; - public int getSerializedSize() { - int size = memoizedSerializedSize; - if (size != -1) return size; - - size = 0; - if (((bitField0_ & 0x00000001) == 0x00000001)) { - size += com.google.protobuf.CodedOutputStream - .computeUInt64Size(1, rowId_); - } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - size += com.google.protobuf.CodedOutputStream - .computeUInt64Size(2, attachmentId_); - } - if (((bitField0_ & 0x00000004) == 0x00000004)) { - size += com.google.protobuf.CodedOutputStream - .computeUInt32Size(3, length_); - } - size += getUnknownFields().getSerializedSize(); - memoizedSerializedSize = size; - return size; - } - - private static final long serialVersionUID = 0L; - @java.lang.Override - protected java.lang.Object writeReplace() - throws java.io.ObjectStreamException { - return super.writeReplace(); - } - - public static org.thoughtcrime.securesms.backup.BackupProtos.Attachment parseFrom( - com.google.protobuf.ByteString data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Attachment parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Attachment parseFrom(byte[] data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Attachment parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Attachment parseFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Attachment parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Attachment parseDelimitedFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Attachment parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Attachment parseFrom( - com.google.protobuf.CodedInputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Attachment parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - - public static Builder newBuilder() { return Builder.create(); } - public Builder newBuilderForType() { return newBuilder(); } - public static Builder newBuilder(org.thoughtcrime.securesms.backup.BackupProtos.Attachment prototype) { - return newBuilder().mergeFrom(prototype); - } - public Builder toBuilder() { return newBuilder(this); } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - Builder builder = new Builder(parent); - return builder; - } - /** - * Protobuf type {@code signal.Attachment} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder<Builder> - implements org.thoughtcrime.securesms.backup.BackupProtos.AttachmentOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Attachment_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Attachment_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.Attachment.class, org.thoughtcrime.securesms.backup.BackupProtos.Attachment.Builder.class); - } - - // Construct using org.thoughtcrime.securesms.backup.BackupProtos.Attachment.newBuilder() - private Builder() { - maybeForceBuilderInitialization(); - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - super(parent); - maybeForceBuilderInitialization(); - } - private void maybeForceBuilderInitialization() { - if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { - } - } - private static Builder create() { - return new Builder(); - } - - public Builder clear() { - super.clear(); - rowId_ = 0L; - bitField0_ = (bitField0_ & ~0x00000001); - attachmentId_ = 0L; - bitField0_ = (bitField0_ & ~0x00000002); - length_ = 0; - bitField0_ = (bitField0_ & ~0x00000004); - return this; - } - - public Builder clone() { - return create().mergeFrom(buildPartial()); - } - - public com.google.protobuf.Descriptors.Descriptor - getDescriptorForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Attachment_descriptor; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.Attachment getDefaultInstanceForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.Attachment.getDefaultInstance(); - } - - public org.thoughtcrime.securesms.backup.BackupProtos.Attachment build() { - org.thoughtcrime.securesms.backup.BackupProtos.Attachment result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.Attachment buildPartial() { - org.thoughtcrime.securesms.backup.BackupProtos.Attachment result = new org.thoughtcrime.securesms.backup.BackupProtos.Attachment(this); - int from_bitField0_ = bitField0_; - int to_bitField0_ = 0; - if (((from_bitField0_ & 0x00000001) == 0x00000001)) { - to_bitField0_ |= 0x00000001; - } - result.rowId_ = rowId_; - if (((from_bitField0_ & 0x00000002) == 0x00000002)) { - to_bitField0_ |= 0x00000002; - } - result.attachmentId_ = attachmentId_; - if (((from_bitField0_ & 0x00000004) == 0x00000004)) { - to_bitField0_ |= 0x00000004; - } - result.length_ = length_; - result.bitField0_ = to_bitField0_; - onBuilt(); - return result; - } - - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof org.thoughtcrime.securesms.backup.BackupProtos.Attachment) { - return mergeFrom((org.thoughtcrime.securesms.backup.BackupProtos.Attachment)other); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom(org.thoughtcrime.securesms.backup.BackupProtos.Attachment other) { - if (other == org.thoughtcrime.securesms.backup.BackupProtos.Attachment.getDefaultInstance()) return this; - if (other.hasRowId()) { - setRowId(other.getRowId()); - } - if (other.hasAttachmentId()) { - setAttachmentId(other.getAttachmentId()); - } - if (other.hasLength()) { - setLength(other.getLength()); - } - this.mergeUnknownFields(other.getUnknownFields()); - return this; - } - - public final boolean isInitialized() { - return true; - } - - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - org.thoughtcrime.securesms.backup.BackupProtos.Attachment parsedMessage = null; - try { - parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - parsedMessage = (org.thoughtcrime.securesms.backup.BackupProtos.Attachment) e.getUnfinishedMessage(); - throw e; - } finally { - if (parsedMessage != null) { - mergeFrom(parsedMessage); - } - } - return this; - } - private int bitField0_; - - // optional uint64 rowId = 1; - private long rowId_ ; - /** - * <code>optional uint64 rowId = 1;</code> - */ - public boolean hasRowId() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional uint64 rowId = 1;</code> - */ - public long getRowId() { - return rowId_; - } - /** - * <code>optional uint64 rowId = 1;</code> - */ - public Builder setRowId(long value) { - bitField0_ |= 0x00000001; - rowId_ = value; - onChanged(); - return this; - } - /** - * <code>optional uint64 rowId = 1;</code> - */ - public Builder clearRowId() { - bitField0_ = (bitField0_ & ~0x00000001); - rowId_ = 0L; - onChanged(); - return this; - } - - // optional uint64 attachmentId = 2; - private long attachmentId_ ; - /** - * <code>optional uint64 attachmentId = 2;</code> - */ - public boolean hasAttachmentId() { - return ((bitField0_ & 0x00000002) == 0x00000002); - } - /** - * <code>optional uint64 attachmentId = 2;</code> - */ - public long getAttachmentId() { - return attachmentId_; - } - /** - * <code>optional uint64 attachmentId = 2;</code> - */ - public Builder setAttachmentId(long value) { - bitField0_ |= 0x00000002; - attachmentId_ = value; - onChanged(); - return this; - } - /** - * <code>optional uint64 attachmentId = 2;</code> - */ - public Builder clearAttachmentId() { - bitField0_ = (bitField0_ & ~0x00000002); - attachmentId_ = 0L; - onChanged(); - return this; - } - - // optional uint32 length = 3; - private int length_ ; - /** - * <code>optional uint32 length = 3;</code> - */ - public boolean hasLength() { - return ((bitField0_ & 0x00000004) == 0x00000004); - } - /** - * <code>optional uint32 length = 3;</code> - */ - public int getLength() { - return length_; - } - /** - * <code>optional uint32 length = 3;</code> - */ - public Builder setLength(int value) { - bitField0_ |= 0x00000004; - length_ = value; - onChanged(); - return this; - } - /** - * <code>optional uint32 length = 3;</code> - */ - public Builder clearLength() { - bitField0_ = (bitField0_ & ~0x00000004); - length_ = 0; - onChanged(); - return this; - } - - // @@protoc_insertion_point(builder_scope:signal.Attachment) - } - - static { - defaultInstance = new Attachment(true); - defaultInstance.initFields(); - } - - // @@protoc_insertion_point(class_scope:signal.Attachment) - } - - public interface StickerOrBuilder - extends com.google.protobuf.MessageOrBuilder { - - // optional uint64 rowId = 1; - /** - * <code>optional uint64 rowId = 1;</code> - */ - boolean hasRowId(); - /** - * <code>optional uint64 rowId = 1;</code> - */ - long getRowId(); - - // optional uint32 length = 2; - /** - * <code>optional uint32 length = 2;</code> - */ - boolean hasLength(); - /** - * <code>optional uint32 length = 2;</code> - */ - int getLength(); - } - /** - * Protobuf type {@code signal.Sticker} - */ - public static final class Sticker extends - com.google.protobuf.GeneratedMessage - implements StickerOrBuilder { - // Use Sticker.newBuilder() to construct. - private Sticker(com.google.protobuf.GeneratedMessage.Builder<?> builder) { - super(builder); - this.unknownFields = builder.getUnknownFields(); - } - private Sticker(boolean noInit) { this.unknownFields = com.google.protobuf.UnknownFieldSet.getDefaultInstance(); } - - private static final Sticker defaultInstance; - public static Sticker getDefaultInstance() { - return defaultInstance; - } - - public Sticker getDefaultInstanceForType() { - return defaultInstance; - } - - private final com.google.protobuf.UnknownFieldSet unknownFields; - @java.lang.Override - public final com.google.protobuf.UnknownFieldSet - getUnknownFields() { - return this.unknownFields; - } - private Sticker( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - initFields(); - int mutable_bitField0_ = 0; - com.google.protobuf.UnknownFieldSet.Builder unknownFields = - com.google.protobuf.UnknownFieldSet.newBuilder(); - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - default: { - if (!parseUnknownField(input, unknownFields, - extensionRegistry, tag)) { - done = true; - } - break; - } - case 8: { - bitField0_ |= 0x00000001; - rowId_ = input.readUInt64(); - break; - } - case 16: { - bitField0_ |= 0x00000002; - length_ = input.readUInt32(); - break; - } - } - } - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(this); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException( - e.getMessage()).setUnfinishedMessage(this); - } finally { - this.unknownFields = unknownFields.build(); - makeExtensionsImmutable(); - } - } - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Sticker_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Sticker_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.Sticker.class, org.thoughtcrime.securesms.backup.BackupProtos.Sticker.Builder.class); - } - - public static com.google.protobuf.Parser<Sticker> PARSER = - new com.google.protobuf.AbstractParser<Sticker>() { - public Sticker parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return new Sticker(input, extensionRegistry); - } - }; - - @java.lang.Override - public com.google.protobuf.Parser<Sticker> getParserForType() { - return PARSER; - } - - private int bitField0_; - // optional uint64 rowId = 1; - public static final int ROWID_FIELD_NUMBER = 1; - private long rowId_; - /** - * <code>optional uint64 rowId = 1;</code> - */ - public boolean hasRowId() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional uint64 rowId = 1;</code> - */ - public long getRowId() { - return rowId_; - } - - // optional uint32 length = 2; - public static final int LENGTH_FIELD_NUMBER = 2; - private int length_; - /** - * <code>optional uint32 length = 2;</code> - */ - public boolean hasLength() { - return ((bitField0_ & 0x00000002) == 0x00000002); - } - /** - * <code>optional uint32 length = 2;</code> - */ - public int getLength() { - return length_; - } - - private void initFields() { - rowId_ = 0L; - length_ = 0; - } - private byte memoizedIsInitialized = -1; - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized != -1) return isInitialized == 1; - - memoizedIsInitialized = 1; - return true; - } - - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - getSerializedSize(); - if (((bitField0_ & 0x00000001) == 0x00000001)) { - output.writeUInt64(1, rowId_); - } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - output.writeUInt32(2, length_); - } - getUnknownFields().writeTo(output); - } - - private int memoizedSerializedSize = -1; - public int getSerializedSize() { - int size = memoizedSerializedSize; - if (size != -1) return size; - - size = 0; - if (((bitField0_ & 0x00000001) == 0x00000001)) { - size += com.google.protobuf.CodedOutputStream - .computeUInt64Size(1, rowId_); - } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - size += com.google.protobuf.CodedOutputStream - .computeUInt32Size(2, length_); - } - size += getUnknownFields().getSerializedSize(); - memoizedSerializedSize = size; - return size; - } - - private static final long serialVersionUID = 0L; - @java.lang.Override - protected java.lang.Object writeReplace() - throws java.io.ObjectStreamException { - return super.writeReplace(); - } - - public static org.thoughtcrime.securesms.backup.BackupProtos.Sticker parseFrom( - com.google.protobuf.ByteString data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Sticker parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Sticker parseFrom(byte[] data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Sticker parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Sticker parseFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Sticker parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Sticker parseDelimitedFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Sticker parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Sticker parseFrom( - com.google.protobuf.CodedInputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Sticker parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - - public static Builder newBuilder() { return Builder.create(); } - public Builder newBuilderForType() { return newBuilder(); } - public static Builder newBuilder(org.thoughtcrime.securesms.backup.BackupProtos.Sticker prototype) { - return newBuilder().mergeFrom(prototype); - } - public Builder toBuilder() { return newBuilder(this); } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - Builder builder = new Builder(parent); - return builder; - } - /** - * Protobuf type {@code signal.Sticker} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder<Builder> - implements org.thoughtcrime.securesms.backup.BackupProtos.StickerOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Sticker_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Sticker_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.Sticker.class, org.thoughtcrime.securesms.backup.BackupProtos.Sticker.Builder.class); - } - - // Construct using org.thoughtcrime.securesms.backup.BackupProtos.Sticker.newBuilder() - private Builder() { - maybeForceBuilderInitialization(); - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - super(parent); - maybeForceBuilderInitialization(); - } - private void maybeForceBuilderInitialization() { - if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { - } - } - private static Builder create() { - return new Builder(); - } - - public Builder clear() { - super.clear(); - rowId_ = 0L; - bitField0_ = (bitField0_ & ~0x00000001); - length_ = 0; - bitField0_ = (bitField0_ & ~0x00000002); - return this; - } - - public Builder clone() { - return create().mergeFrom(buildPartial()); - } - - public com.google.protobuf.Descriptors.Descriptor - getDescriptorForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Sticker_descriptor; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.Sticker getDefaultInstanceForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.Sticker.getDefaultInstance(); - } - - public org.thoughtcrime.securesms.backup.BackupProtos.Sticker build() { - org.thoughtcrime.securesms.backup.BackupProtos.Sticker result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.Sticker buildPartial() { - org.thoughtcrime.securesms.backup.BackupProtos.Sticker result = new org.thoughtcrime.securesms.backup.BackupProtos.Sticker(this); - int from_bitField0_ = bitField0_; - int to_bitField0_ = 0; - if (((from_bitField0_ & 0x00000001) == 0x00000001)) { - to_bitField0_ |= 0x00000001; - } - result.rowId_ = rowId_; - if (((from_bitField0_ & 0x00000002) == 0x00000002)) { - to_bitField0_ |= 0x00000002; - } - result.length_ = length_; - result.bitField0_ = to_bitField0_; - onBuilt(); - return result; - } - - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof org.thoughtcrime.securesms.backup.BackupProtos.Sticker) { - return mergeFrom((org.thoughtcrime.securesms.backup.BackupProtos.Sticker)other); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom(org.thoughtcrime.securesms.backup.BackupProtos.Sticker other) { - if (other == org.thoughtcrime.securesms.backup.BackupProtos.Sticker.getDefaultInstance()) return this; - if (other.hasRowId()) { - setRowId(other.getRowId()); - } - if (other.hasLength()) { - setLength(other.getLength()); - } - this.mergeUnknownFields(other.getUnknownFields()); - return this; - } - - public final boolean isInitialized() { - return true; - } - - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - org.thoughtcrime.securesms.backup.BackupProtos.Sticker parsedMessage = null; - try { - parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - parsedMessage = (org.thoughtcrime.securesms.backup.BackupProtos.Sticker) e.getUnfinishedMessage(); - throw e; - } finally { - if (parsedMessage != null) { - mergeFrom(parsedMessage); - } - } - return this; - } - private int bitField0_; - - // optional uint64 rowId = 1; - private long rowId_ ; - /** - * <code>optional uint64 rowId = 1;</code> - */ - public boolean hasRowId() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional uint64 rowId = 1;</code> - */ - public long getRowId() { - return rowId_; - } - /** - * <code>optional uint64 rowId = 1;</code> - */ - public Builder setRowId(long value) { - bitField0_ |= 0x00000001; - rowId_ = value; - onChanged(); - return this; - } - /** - * <code>optional uint64 rowId = 1;</code> - */ - public Builder clearRowId() { - bitField0_ = (bitField0_ & ~0x00000001); - rowId_ = 0L; - onChanged(); - return this; - } - - // optional uint32 length = 2; - private int length_ ; - /** - * <code>optional uint32 length = 2;</code> - */ - public boolean hasLength() { - return ((bitField0_ & 0x00000002) == 0x00000002); - } - /** - * <code>optional uint32 length = 2;</code> - */ - public int getLength() { - return length_; - } - /** - * <code>optional uint32 length = 2;</code> - */ - public Builder setLength(int value) { - bitField0_ |= 0x00000002; - length_ = value; - onChanged(); - return this; - } - /** - * <code>optional uint32 length = 2;</code> - */ - public Builder clearLength() { - bitField0_ = (bitField0_ & ~0x00000002); - length_ = 0; - onChanged(); - return this; - } - - // @@protoc_insertion_point(builder_scope:signal.Sticker) - } - - static { - defaultInstance = new Sticker(true); - defaultInstance.initFields(); - } - - // @@protoc_insertion_point(class_scope:signal.Sticker) - } - - public interface AvatarOrBuilder - extends com.google.protobuf.MessageOrBuilder { - - // optional string name = 1; - /** - * <code>optional string name = 1;</code> - */ - boolean hasName(); - /** - * <code>optional string name = 1;</code> - */ - java.lang.String getName(); - /** - * <code>optional string name = 1;</code> - */ - com.google.protobuf.ByteString - getNameBytes(); - - // optional uint32 length = 2; - /** - * <code>optional uint32 length = 2;</code> - */ - boolean hasLength(); - /** - * <code>optional uint32 length = 2;</code> - */ - int getLength(); - } - /** - * Protobuf type {@code signal.Avatar} - */ - public static final class Avatar extends - com.google.protobuf.GeneratedMessage - implements AvatarOrBuilder { - // Use Avatar.newBuilder() to construct. - private Avatar(com.google.protobuf.GeneratedMessage.Builder<?> builder) { - super(builder); - this.unknownFields = builder.getUnknownFields(); - } - private Avatar(boolean noInit) { this.unknownFields = com.google.protobuf.UnknownFieldSet.getDefaultInstance(); } - - private static final Avatar defaultInstance; - public static Avatar getDefaultInstance() { - return defaultInstance; - } - - public Avatar getDefaultInstanceForType() { - return defaultInstance; - } - - private final com.google.protobuf.UnknownFieldSet unknownFields; - @java.lang.Override - public final com.google.protobuf.UnknownFieldSet - getUnknownFields() { - return this.unknownFields; - } - private Avatar( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - initFields(); - int mutable_bitField0_ = 0; - com.google.protobuf.UnknownFieldSet.Builder unknownFields = - com.google.protobuf.UnknownFieldSet.newBuilder(); - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - default: { - if (!parseUnknownField(input, unknownFields, - extensionRegistry, tag)) { - done = true; - } - break; - } - case 10: { - bitField0_ |= 0x00000001; - name_ = input.readBytes(); - break; - } - case 16: { - bitField0_ |= 0x00000002; - length_ = input.readUInt32(); - break; - } - } - } - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(this); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException( - e.getMessage()).setUnfinishedMessage(this); - } finally { - this.unknownFields = unknownFields.build(); - makeExtensionsImmutable(); - } - } - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Avatar_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Avatar_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.Avatar.class, org.thoughtcrime.securesms.backup.BackupProtos.Avatar.Builder.class); - } - - public static com.google.protobuf.Parser<Avatar> PARSER = - new com.google.protobuf.AbstractParser<Avatar>() { - public Avatar parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return new Avatar(input, extensionRegistry); - } - }; - - @java.lang.Override - public com.google.protobuf.Parser<Avatar> getParserForType() { - return PARSER; - } - - private int bitField0_; - // optional string name = 1; - public static final int NAME_FIELD_NUMBER = 1; - private java.lang.Object name_; - /** - * <code>optional string name = 1;</code> - */ - public boolean hasName() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional string name = 1;</code> - */ - public java.lang.String getName() { - java.lang.Object ref = name_; - if (ref instanceof java.lang.String) { - return (java.lang.String) ref; - } else { - com.google.protobuf.ByteString bs = - (com.google.protobuf.ByteString) ref; - java.lang.String s = bs.toStringUtf8(); - if (bs.isValidUtf8()) { - name_ = s; - } - return s; - } - } - /** - * <code>optional string name = 1;</code> - */ - public com.google.protobuf.ByteString - getNameBytes() { - java.lang.Object ref = name_; - if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = - com.google.protobuf.ByteString.copyFromUtf8( - (java.lang.String) ref); - name_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - - // optional uint32 length = 2; - public static final int LENGTH_FIELD_NUMBER = 2; - private int length_; - /** - * <code>optional uint32 length = 2;</code> - */ - public boolean hasLength() { - return ((bitField0_ & 0x00000002) == 0x00000002); - } - /** - * <code>optional uint32 length = 2;</code> - */ - public int getLength() { - return length_; - } - - private void initFields() { - name_ = ""; - length_ = 0; - } - private byte memoizedIsInitialized = -1; - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized != -1) return isInitialized == 1; - - memoizedIsInitialized = 1; - return true; - } - - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - getSerializedSize(); - if (((bitField0_ & 0x00000001) == 0x00000001)) { - output.writeBytes(1, getNameBytes()); - } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - output.writeUInt32(2, length_); - } - getUnknownFields().writeTo(output); - } - - private int memoizedSerializedSize = -1; - public int getSerializedSize() { - int size = memoizedSerializedSize; - if (size != -1) return size; - - size = 0; - if (((bitField0_ & 0x00000001) == 0x00000001)) { - size += com.google.protobuf.CodedOutputStream - .computeBytesSize(1, getNameBytes()); - } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - size += com.google.protobuf.CodedOutputStream - .computeUInt32Size(2, length_); - } - size += getUnknownFields().getSerializedSize(); - memoizedSerializedSize = size; - return size; - } - - private static final long serialVersionUID = 0L; - @java.lang.Override - protected java.lang.Object writeReplace() - throws java.io.ObjectStreamException { - return super.writeReplace(); - } - - public static org.thoughtcrime.securesms.backup.BackupProtos.Avatar parseFrom( - com.google.protobuf.ByteString data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Avatar parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Avatar parseFrom(byte[] data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Avatar parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Avatar parseFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Avatar parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Avatar parseDelimitedFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Avatar parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Avatar parseFrom( - com.google.protobuf.CodedInputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Avatar parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - - public static Builder newBuilder() { return Builder.create(); } - public Builder newBuilderForType() { return newBuilder(); } - public static Builder newBuilder(org.thoughtcrime.securesms.backup.BackupProtos.Avatar prototype) { - return newBuilder().mergeFrom(prototype); - } - public Builder toBuilder() { return newBuilder(this); } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - Builder builder = new Builder(parent); - return builder; - } - /** - * Protobuf type {@code signal.Avatar} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder<Builder> - implements org.thoughtcrime.securesms.backup.BackupProtos.AvatarOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Avatar_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Avatar_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.Avatar.class, org.thoughtcrime.securesms.backup.BackupProtos.Avatar.Builder.class); - } - - // Construct using org.thoughtcrime.securesms.backup.BackupProtos.Avatar.newBuilder() - private Builder() { - maybeForceBuilderInitialization(); - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - super(parent); - maybeForceBuilderInitialization(); - } - private void maybeForceBuilderInitialization() { - if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { - } - } - private static Builder create() { - return new Builder(); - } - - public Builder clear() { - super.clear(); - name_ = ""; - bitField0_ = (bitField0_ & ~0x00000001); - length_ = 0; - bitField0_ = (bitField0_ & ~0x00000002); - return this; - } - - public Builder clone() { - return create().mergeFrom(buildPartial()); - } - - public com.google.protobuf.Descriptors.Descriptor - getDescriptorForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Avatar_descriptor; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.Avatar getDefaultInstanceForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.Avatar.getDefaultInstance(); - } - - public org.thoughtcrime.securesms.backup.BackupProtos.Avatar build() { - org.thoughtcrime.securesms.backup.BackupProtos.Avatar result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.Avatar buildPartial() { - org.thoughtcrime.securesms.backup.BackupProtos.Avatar result = new org.thoughtcrime.securesms.backup.BackupProtos.Avatar(this); - int from_bitField0_ = bitField0_; - int to_bitField0_ = 0; - if (((from_bitField0_ & 0x00000001) == 0x00000001)) { - to_bitField0_ |= 0x00000001; - } - result.name_ = name_; - if (((from_bitField0_ & 0x00000002) == 0x00000002)) { - to_bitField0_ |= 0x00000002; - } - result.length_ = length_; - result.bitField0_ = to_bitField0_; - onBuilt(); - return result; - } - - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof org.thoughtcrime.securesms.backup.BackupProtos.Avatar) { - return mergeFrom((org.thoughtcrime.securesms.backup.BackupProtos.Avatar)other); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom(org.thoughtcrime.securesms.backup.BackupProtos.Avatar other) { - if (other == org.thoughtcrime.securesms.backup.BackupProtos.Avatar.getDefaultInstance()) return this; - if (other.hasName()) { - bitField0_ |= 0x00000001; - name_ = other.name_; - onChanged(); - } - if (other.hasLength()) { - setLength(other.getLength()); - } - this.mergeUnknownFields(other.getUnknownFields()); - return this; - } - - public final boolean isInitialized() { - return true; - } - - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - org.thoughtcrime.securesms.backup.BackupProtos.Avatar parsedMessage = null; - try { - parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - parsedMessage = (org.thoughtcrime.securesms.backup.BackupProtos.Avatar) e.getUnfinishedMessage(); - throw e; - } finally { - if (parsedMessage != null) { - mergeFrom(parsedMessage); - } - } - return this; - } - private int bitField0_; - - // optional string name = 1; - private java.lang.Object name_ = ""; - /** - * <code>optional string name = 1;</code> - */ - public boolean hasName() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional string name = 1;</code> - */ - public java.lang.String getName() { - java.lang.Object ref = name_; - if (!(ref instanceof java.lang.String)) { - java.lang.String s = ((com.google.protobuf.ByteString) ref) - .toStringUtf8(); - name_ = s; - return s; - } else { - return (java.lang.String) ref; - } - } - /** - * <code>optional string name = 1;</code> - */ - public com.google.protobuf.ByteString - getNameBytes() { - java.lang.Object ref = name_; - if (ref instanceof String) { - com.google.protobuf.ByteString b = - com.google.protobuf.ByteString.copyFromUtf8( - (java.lang.String) ref); - name_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - /** - * <code>optional string name = 1;</code> - */ - public Builder setName( - java.lang.String value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000001; - name_ = value; - onChanged(); - return this; - } - /** - * <code>optional string name = 1;</code> - */ - public Builder clearName() { - bitField0_ = (bitField0_ & ~0x00000001); - name_ = getDefaultInstance().getName(); - onChanged(); - return this; - } - /** - * <code>optional string name = 1;</code> - */ - public Builder setNameBytes( - com.google.protobuf.ByteString value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000001; - name_ = value; - onChanged(); - return this; - } - - // optional uint32 length = 2; - private int length_ ; - /** - * <code>optional uint32 length = 2;</code> - */ - public boolean hasLength() { - return ((bitField0_ & 0x00000002) == 0x00000002); - } - /** - * <code>optional uint32 length = 2;</code> - */ - public int getLength() { - return length_; - } - /** - * <code>optional uint32 length = 2;</code> - */ - public Builder setLength(int value) { - bitField0_ |= 0x00000002; - length_ = value; - onChanged(); - return this; - } - /** - * <code>optional uint32 length = 2;</code> - */ - public Builder clearLength() { - bitField0_ = (bitField0_ & ~0x00000002); - length_ = 0; - onChanged(); - return this; - } - - // @@protoc_insertion_point(builder_scope:signal.Avatar) - } - - static { - defaultInstance = new Avatar(true); - defaultInstance.initFields(); - } - - // @@protoc_insertion_point(class_scope:signal.Avatar) - } - - public interface DatabaseVersionOrBuilder - extends com.google.protobuf.MessageOrBuilder { - - // optional uint32 version = 1; - /** - * <code>optional uint32 version = 1;</code> - */ - boolean hasVersion(); - /** - * <code>optional uint32 version = 1;</code> - */ - int getVersion(); - } - /** - * Protobuf type {@code signal.DatabaseVersion} - */ - public static final class DatabaseVersion extends - com.google.protobuf.GeneratedMessage - implements DatabaseVersionOrBuilder { - // Use DatabaseVersion.newBuilder() to construct. - private DatabaseVersion(com.google.protobuf.GeneratedMessage.Builder<?> builder) { - super(builder); - this.unknownFields = builder.getUnknownFields(); - } - private DatabaseVersion(boolean noInit) { this.unknownFields = com.google.protobuf.UnknownFieldSet.getDefaultInstance(); } - - private static final DatabaseVersion defaultInstance; - public static DatabaseVersion getDefaultInstance() { - return defaultInstance; - } - - public DatabaseVersion getDefaultInstanceForType() { - return defaultInstance; - } - - private final com.google.protobuf.UnknownFieldSet unknownFields; - @java.lang.Override - public final com.google.protobuf.UnknownFieldSet - getUnknownFields() { - return this.unknownFields; - } - private DatabaseVersion( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - initFields(); - int mutable_bitField0_ = 0; - com.google.protobuf.UnknownFieldSet.Builder unknownFields = - com.google.protobuf.UnknownFieldSet.newBuilder(); - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - default: { - if (!parseUnknownField(input, unknownFields, - extensionRegistry, tag)) { - done = true; - } - break; - } - case 8: { - bitField0_ |= 0x00000001; - version_ = input.readUInt32(); - break; - } - } - } - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(this); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException( - e.getMessage()).setUnfinishedMessage(this); - } finally { - this.unknownFields = unknownFields.build(); - makeExtensionsImmutable(); - } - } - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_DatabaseVersion_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_DatabaseVersion_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.class, org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.Builder.class); - } - - public static com.google.protobuf.Parser<DatabaseVersion> PARSER = - new com.google.protobuf.AbstractParser<DatabaseVersion>() { - public DatabaseVersion parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return new DatabaseVersion(input, extensionRegistry); - } - }; - - @java.lang.Override - public com.google.protobuf.Parser<DatabaseVersion> getParserForType() { - return PARSER; - } - - private int bitField0_; - // optional uint32 version = 1; - public static final int VERSION_FIELD_NUMBER = 1; - private int version_; - /** - * <code>optional uint32 version = 1;</code> - */ - public boolean hasVersion() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional uint32 version = 1;</code> - */ - public int getVersion() { - return version_; - } - - private void initFields() { - version_ = 0; - } - private byte memoizedIsInitialized = -1; - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized != -1) return isInitialized == 1; - - memoizedIsInitialized = 1; - return true; - } - - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - getSerializedSize(); - if (((bitField0_ & 0x00000001) == 0x00000001)) { - output.writeUInt32(1, version_); - } - getUnknownFields().writeTo(output); - } - - private int memoizedSerializedSize = -1; - public int getSerializedSize() { - int size = memoizedSerializedSize; - if (size != -1) return size; - - size = 0; - if (((bitField0_ & 0x00000001) == 0x00000001)) { - size += com.google.protobuf.CodedOutputStream - .computeUInt32Size(1, version_); - } - size += getUnknownFields().getSerializedSize(); - memoizedSerializedSize = size; - return size; - } - - private static final long serialVersionUID = 0L; - @java.lang.Override - protected java.lang.Object writeReplace() - throws java.io.ObjectStreamException { - return super.writeReplace(); - } - - public static org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion parseFrom( - com.google.protobuf.ByteString data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion parseFrom(byte[] data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion parseFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion parseDelimitedFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion parseFrom( - com.google.protobuf.CodedInputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - - public static Builder newBuilder() { return Builder.create(); } - public Builder newBuilderForType() { return newBuilder(); } - public static Builder newBuilder(org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion prototype) { - return newBuilder().mergeFrom(prototype); - } - public Builder toBuilder() { return newBuilder(this); } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - Builder builder = new Builder(parent); - return builder; - } - /** - * Protobuf type {@code signal.DatabaseVersion} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder<Builder> - implements org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersionOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_DatabaseVersion_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_DatabaseVersion_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.class, org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.Builder.class); - } - - // Construct using org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.newBuilder() - private Builder() { - maybeForceBuilderInitialization(); - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - super(parent); - maybeForceBuilderInitialization(); - } - private void maybeForceBuilderInitialization() { - if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { - } - } - private static Builder create() { - return new Builder(); - } - - public Builder clear() { - super.clear(); - version_ = 0; - bitField0_ = (bitField0_ & ~0x00000001); - return this; - } - - public Builder clone() { - return create().mergeFrom(buildPartial()); - } - - public com.google.protobuf.Descriptors.Descriptor - getDescriptorForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_DatabaseVersion_descriptor; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion getDefaultInstanceForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.getDefaultInstance(); - } - - public org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion build() { - org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion buildPartial() { - org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion result = new org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion(this); - int from_bitField0_ = bitField0_; - int to_bitField0_ = 0; - if (((from_bitField0_ & 0x00000001) == 0x00000001)) { - to_bitField0_ |= 0x00000001; - } - result.version_ = version_; - result.bitField0_ = to_bitField0_; - onBuilt(); - return result; - } - - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion) { - return mergeFrom((org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion)other); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom(org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion other) { - if (other == org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.getDefaultInstance()) return this; - if (other.hasVersion()) { - setVersion(other.getVersion()); - } - this.mergeUnknownFields(other.getUnknownFields()); - return this; - } - - public final boolean isInitialized() { - return true; - } - - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion parsedMessage = null; - try { - parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - parsedMessage = (org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion) e.getUnfinishedMessage(); - throw e; - } finally { - if (parsedMessage != null) { - mergeFrom(parsedMessage); - } - } - return this; - } - private int bitField0_; - - // optional uint32 version = 1; - private int version_ ; - /** - * <code>optional uint32 version = 1;</code> - */ - public boolean hasVersion() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional uint32 version = 1;</code> - */ - public int getVersion() { - return version_; - } - /** - * <code>optional uint32 version = 1;</code> - */ - public Builder setVersion(int value) { - bitField0_ |= 0x00000001; - version_ = value; - onChanged(); - return this; - } - /** - * <code>optional uint32 version = 1;</code> - */ - public Builder clearVersion() { - bitField0_ = (bitField0_ & ~0x00000001); - version_ = 0; - onChanged(); - return this; - } - - // @@protoc_insertion_point(builder_scope:signal.DatabaseVersion) - } - - static { - defaultInstance = new DatabaseVersion(true); - defaultInstance.initFields(); - } - - // @@protoc_insertion_point(class_scope:signal.DatabaseVersion) - } - - public interface HeaderOrBuilder - extends com.google.protobuf.MessageOrBuilder { - - // optional bytes iv = 1; - /** - * <code>optional bytes iv = 1;</code> - */ - boolean hasIv(); - /** - * <code>optional bytes iv = 1;</code> - */ - com.google.protobuf.ByteString getIv(); - - // optional bytes salt = 2; - /** - * <code>optional bytes salt = 2;</code> - */ - boolean hasSalt(); - /** - * <code>optional bytes salt = 2;</code> - */ - com.google.protobuf.ByteString getSalt(); - } - /** - * Protobuf type {@code signal.Header} - */ - public static final class Header extends - com.google.protobuf.GeneratedMessage - implements HeaderOrBuilder { - // Use Header.newBuilder() to construct. - private Header(com.google.protobuf.GeneratedMessage.Builder<?> builder) { - super(builder); - this.unknownFields = builder.getUnknownFields(); - } - private Header(boolean noInit) { this.unknownFields = com.google.protobuf.UnknownFieldSet.getDefaultInstance(); } - - private static final Header defaultInstance; - public static Header getDefaultInstance() { - return defaultInstance; - } - - public Header getDefaultInstanceForType() { - return defaultInstance; - } - - private final com.google.protobuf.UnknownFieldSet unknownFields; - @java.lang.Override - public final com.google.protobuf.UnknownFieldSet - getUnknownFields() { - return this.unknownFields; - } - private Header( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - initFields(); - int mutable_bitField0_ = 0; - com.google.protobuf.UnknownFieldSet.Builder unknownFields = - com.google.protobuf.UnknownFieldSet.newBuilder(); - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - default: { - if (!parseUnknownField(input, unknownFields, - extensionRegistry, tag)) { - done = true; - } - break; - } - case 10: { - bitField0_ |= 0x00000001; - iv_ = input.readBytes(); - break; - } - case 18: { - bitField0_ |= 0x00000002; - salt_ = input.readBytes(); - break; - } - } - } - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(this); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException( - e.getMessage()).setUnfinishedMessage(this); - } finally { - this.unknownFields = unknownFields.build(); - makeExtensionsImmutable(); - } - } - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Header_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Header_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.Header.class, org.thoughtcrime.securesms.backup.BackupProtos.Header.Builder.class); - } - - public static com.google.protobuf.Parser<Header> PARSER = - new com.google.protobuf.AbstractParser<Header>() { - public Header parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return new Header(input, extensionRegistry); - } - }; - - @java.lang.Override - public com.google.protobuf.Parser<Header> getParserForType() { - return PARSER; - } - - private int bitField0_; - // optional bytes iv = 1; - public static final int IV_FIELD_NUMBER = 1; - private com.google.protobuf.ByteString iv_; - /** - * <code>optional bytes iv = 1;</code> - */ - public boolean hasIv() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional bytes iv = 1;</code> - */ - public com.google.protobuf.ByteString getIv() { - return iv_; - } - - // optional bytes salt = 2; - public static final int SALT_FIELD_NUMBER = 2; - private com.google.protobuf.ByteString salt_; - /** - * <code>optional bytes salt = 2;</code> - */ - public boolean hasSalt() { - return ((bitField0_ & 0x00000002) == 0x00000002); - } - /** - * <code>optional bytes salt = 2;</code> - */ - public com.google.protobuf.ByteString getSalt() { - return salt_; - } - - private void initFields() { - iv_ = com.google.protobuf.ByteString.EMPTY; - salt_ = com.google.protobuf.ByteString.EMPTY; - } - private byte memoizedIsInitialized = -1; - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized != -1) return isInitialized == 1; - - memoizedIsInitialized = 1; - return true; - } - - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - getSerializedSize(); - if (((bitField0_ & 0x00000001) == 0x00000001)) { - output.writeBytes(1, iv_); - } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - output.writeBytes(2, salt_); - } - getUnknownFields().writeTo(output); - } - - private int memoizedSerializedSize = -1; - public int getSerializedSize() { - int size = memoizedSerializedSize; - if (size != -1) return size; - - size = 0; - if (((bitField0_ & 0x00000001) == 0x00000001)) { - size += com.google.protobuf.CodedOutputStream - .computeBytesSize(1, iv_); - } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - size += com.google.protobuf.CodedOutputStream - .computeBytesSize(2, salt_); - } - size += getUnknownFields().getSerializedSize(); - memoizedSerializedSize = size; - return size; - } - - private static final long serialVersionUID = 0L; - @java.lang.Override - protected java.lang.Object writeReplace() - throws java.io.ObjectStreamException { - return super.writeReplace(); - } - - public static org.thoughtcrime.securesms.backup.BackupProtos.Header parseFrom( - com.google.protobuf.ByteString data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Header parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Header parseFrom(byte[] data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Header parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Header parseFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Header parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Header parseDelimitedFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Header parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Header parseFrom( - com.google.protobuf.CodedInputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.Header parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - - public static Builder newBuilder() { return Builder.create(); } - public Builder newBuilderForType() { return newBuilder(); } - public static Builder newBuilder(org.thoughtcrime.securesms.backup.BackupProtos.Header prototype) { - return newBuilder().mergeFrom(prototype); - } - public Builder toBuilder() { return newBuilder(this); } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - Builder builder = new Builder(parent); - return builder; - } - /** - * Protobuf type {@code signal.Header} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder<Builder> - implements org.thoughtcrime.securesms.backup.BackupProtos.HeaderOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Header_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Header_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.Header.class, org.thoughtcrime.securesms.backup.BackupProtos.Header.Builder.class); - } - - // Construct using org.thoughtcrime.securesms.backup.BackupProtos.Header.newBuilder() - private Builder() { - maybeForceBuilderInitialization(); - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - super(parent); - maybeForceBuilderInitialization(); - } - private void maybeForceBuilderInitialization() { - if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { - } - } - private static Builder create() { - return new Builder(); - } - - public Builder clear() { - super.clear(); - iv_ = com.google.protobuf.ByteString.EMPTY; - bitField0_ = (bitField0_ & ~0x00000001); - salt_ = com.google.protobuf.ByteString.EMPTY; - bitField0_ = (bitField0_ & ~0x00000002); - return this; - } - - public Builder clone() { - return create().mergeFrom(buildPartial()); - } - - public com.google.protobuf.Descriptors.Descriptor - getDescriptorForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_Header_descriptor; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.Header getDefaultInstanceForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.Header.getDefaultInstance(); - } - - public org.thoughtcrime.securesms.backup.BackupProtos.Header build() { - org.thoughtcrime.securesms.backup.BackupProtos.Header result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.Header buildPartial() { - org.thoughtcrime.securesms.backup.BackupProtos.Header result = new org.thoughtcrime.securesms.backup.BackupProtos.Header(this); - int from_bitField0_ = bitField0_; - int to_bitField0_ = 0; - if (((from_bitField0_ & 0x00000001) == 0x00000001)) { - to_bitField0_ |= 0x00000001; - } - result.iv_ = iv_; - if (((from_bitField0_ & 0x00000002) == 0x00000002)) { - to_bitField0_ |= 0x00000002; - } - result.salt_ = salt_; - result.bitField0_ = to_bitField0_; - onBuilt(); - return result; - } - - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof org.thoughtcrime.securesms.backup.BackupProtos.Header) { - return mergeFrom((org.thoughtcrime.securesms.backup.BackupProtos.Header)other); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom(org.thoughtcrime.securesms.backup.BackupProtos.Header other) { - if (other == org.thoughtcrime.securesms.backup.BackupProtos.Header.getDefaultInstance()) return this; - if (other.hasIv()) { - setIv(other.getIv()); - } - if (other.hasSalt()) { - setSalt(other.getSalt()); - } - this.mergeUnknownFields(other.getUnknownFields()); - return this; - } - - public final boolean isInitialized() { - return true; - } - - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - org.thoughtcrime.securesms.backup.BackupProtos.Header parsedMessage = null; - try { - parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - parsedMessage = (org.thoughtcrime.securesms.backup.BackupProtos.Header) e.getUnfinishedMessage(); - throw e; - } finally { - if (parsedMessage != null) { - mergeFrom(parsedMessage); - } - } - return this; - } - private int bitField0_; - - // optional bytes iv = 1; - private com.google.protobuf.ByteString iv_ = com.google.protobuf.ByteString.EMPTY; - /** - * <code>optional bytes iv = 1;</code> - */ - public boolean hasIv() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional bytes iv = 1;</code> - */ - public com.google.protobuf.ByteString getIv() { - return iv_; - } - /** - * <code>optional bytes iv = 1;</code> - */ - public Builder setIv(com.google.protobuf.ByteString value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000001; - iv_ = value; - onChanged(); - return this; - } - /** - * <code>optional bytes iv = 1;</code> - */ - public Builder clearIv() { - bitField0_ = (bitField0_ & ~0x00000001); - iv_ = getDefaultInstance().getIv(); - onChanged(); - return this; - } - - // optional bytes salt = 2; - private com.google.protobuf.ByteString salt_ = com.google.protobuf.ByteString.EMPTY; - /** - * <code>optional bytes salt = 2;</code> - */ - public boolean hasSalt() { - return ((bitField0_ & 0x00000002) == 0x00000002); - } - /** - * <code>optional bytes salt = 2;</code> - */ - public com.google.protobuf.ByteString getSalt() { - return salt_; - } - /** - * <code>optional bytes salt = 2;</code> - */ - public Builder setSalt(com.google.protobuf.ByteString value) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000002; - salt_ = value; - onChanged(); - return this; - } - /** - * <code>optional bytes salt = 2;</code> - */ - public Builder clearSalt() { - bitField0_ = (bitField0_ & ~0x00000002); - salt_ = getDefaultInstance().getSalt(); - onChanged(); - return this; - } - - // @@protoc_insertion_point(builder_scope:signal.Header) - } - - static { - defaultInstance = new Header(true); - defaultInstance.initFields(); - } - - // @@protoc_insertion_point(class_scope:signal.Header) - } - - public interface BackupFrameOrBuilder - extends com.google.protobuf.MessageOrBuilder { - - // optional .signal.Header header = 1; - /** - * <code>optional .signal.Header header = 1;</code> - */ - boolean hasHeader(); - /** - * <code>optional .signal.Header header = 1;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.Header getHeader(); - /** - * <code>optional .signal.Header header = 1;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.HeaderOrBuilder getHeaderOrBuilder(); - - // optional .signal.SqlStatement statement = 2; - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - boolean hasStatement(); - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement getStatement(); - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatementOrBuilder getStatementOrBuilder(); - - // optional .signal.SharedPreference preference = 3; - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - boolean hasPreference(); - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference getPreference(); - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.SharedPreferenceOrBuilder getPreferenceOrBuilder(); - - // optional .signal.Attachment attachment = 4; - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - boolean hasAttachment(); - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.Attachment getAttachment(); - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.AttachmentOrBuilder getAttachmentOrBuilder(); - - // optional .signal.DatabaseVersion version = 5; - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - boolean hasVersion(); - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion getVersion(); - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersionOrBuilder getVersionOrBuilder(); - - // optional bool end = 6; - /** - * <code>optional bool end = 6;</code> - */ - boolean hasEnd(); - /** - * <code>optional bool end = 6;</code> - */ - boolean getEnd(); - - // optional .signal.Avatar avatar = 7; - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - boolean hasAvatar(); - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.Avatar getAvatar(); - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.AvatarOrBuilder getAvatarOrBuilder(); - - // optional .signal.Sticker sticker = 8; - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - boolean hasSticker(); - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.Sticker getSticker(); - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - org.thoughtcrime.securesms.backup.BackupProtos.StickerOrBuilder getStickerOrBuilder(); - } - /** - * Protobuf type {@code signal.BackupFrame} - */ - public static final class BackupFrame extends - com.google.protobuf.GeneratedMessage - implements BackupFrameOrBuilder { - // Use BackupFrame.newBuilder() to construct. - private BackupFrame(com.google.protobuf.GeneratedMessage.Builder<?> builder) { - super(builder); - this.unknownFields = builder.getUnknownFields(); - } - private BackupFrame(boolean noInit) { this.unknownFields = com.google.protobuf.UnknownFieldSet.getDefaultInstance(); } - - private static final BackupFrame defaultInstance; - public static BackupFrame getDefaultInstance() { - return defaultInstance; - } - - public BackupFrame getDefaultInstanceForType() { - return defaultInstance; - } - - private final com.google.protobuf.UnknownFieldSet unknownFields; - @java.lang.Override - public final com.google.protobuf.UnknownFieldSet - getUnknownFields() { - return this.unknownFields; - } - private BackupFrame( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - initFields(); - int mutable_bitField0_ = 0; - com.google.protobuf.UnknownFieldSet.Builder unknownFields = - com.google.protobuf.UnknownFieldSet.newBuilder(); - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - default: { - if (!parseUnknownField(input, unknownFields, - extensionRegistry, tag)) { - done = true; - } - break; - } - case 10: { - org.thoughtcrime.securesms.backup.BackupProtos.Header.Builder subBuilder = null; - if (((bitField0_ & 0x00000001) == 0x00000001)) { - subBuilder = header_.toBuilder(); - } - header_ = input.readMessage(org.thoughtcrime.securesms.backup.BackupProtos.Header.PARSER, extensionRegistry); - if (subBuilder != null) { - subBuilder.mergeFrom(header_); - header_ = subBuilder.buildPartial(); - } - bitField0_ |= 0x00000001; - break; - } - case 18: { - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.Builder subBuilder = null; - if (((bitField0_ & 0x00000002) == 0x00000002)) { - subBuilder = statement_.toBuilder(); - } - statement_ = input.readMessage(org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.PARSER, extensionRegistry); - if (subBuilder != null) { - subBuilder.mergeFrom(statement_); - statement_ = subBuilder.buildPartial(); - } - bitField0_ |= 0x00000002; - break; - } - case 26: { - org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.Builder subBuilder = null; - if (((bitField0_ & 0x00000004) == 0x00000004)) { - subBuilder = preference_.toBuilder(); - } - preference_ = input.readMessage(org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.PARSER, extensionRegistry); - if (subBuilder != null) { - subBuilder.mergeFrom(preference_); - preference_ = subBuilder.buildPartial(); - } - bitField0_ |= 0x00000004; - break; - } - case 34: { - org.thoughtcrime.securesms.backup.BackupProtos.Attachment.Builder subBuilder = null; - if (((bitField0_ & 0x00000008) == 0x00000008)) { - subBuilder = attachment_.toBuilder(); - } - attachment_ = input.readMessage(org.thoughtcrime.securesms.backup.BackupProtos.Attachment.PARSER, extensionRegistry); - if (subBuilder != null) { - subBuilder.mergeFrom(attachment_); - attachment_ = subBuilder.buildPartial(); - } - bitField0_ |= 0x00000008; - break; - } - case 42: { - org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.Builder subBuilder = null; - if (((bitField0_ & 0x00000010) == 0x00000010)) { - subBuilder = version_.toBuilder(); - } - version_ = input.readMessage(org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.PARSER, extensionRegistry); - if (subBuilder != null) { - subBuilder.mergeFrom(version_); - version_ = subBuilder.buildPartial(); - } - bitField0_ |= 0x00000010; - break; - } - case 48: { - bitField0_ |= 0x00000020; - end_ = input.readBool(); - break; - } - case 58: { - org.thoughtcrime.securesms.backup.BackupProtos.Avatar.Builder subBuilder = null; - if (((bitField0_ & 0x00000040) == 0x00000040)) { - subBuilder = avatar_.toBuilder(); - } - avatar_ = input.readMessage(org.thoughtcrime.securesms.backup.BackupProtos.Avatar.PARSER, extensionRegistry); - if (subBuilder != null) { - subBuilder.mergeFrom(avatar_); - avatar_ = subBuilder.buildPartial(); - } - bitField0_ |= 0x00000040; - break; - } - case 66: { - org.thoughtcrime.securesms.backup.BackupProtos.Sticker.Builder subBuilder = null; - if (((bitField0_ & 0x00000080) == 0x00000080)) { - subBuilder = sticker_.toBuilder(); - } - sticker_ = input.readMessage(org.thoughtcrime.securesms.backup.BackupProtos.Sticker.PARSER, extensionRegistry); - if (subBuilder != null) { - subBuilder.mergeFrom(sticker_); - sticker_ = subBuilder.buildPartial(); - } - bitField0_ |= 0x00000080; - break; - } - } - } - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(this); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException( - e.getMessage()).setUnfinishedMessage(this); - } finally { - this.unknownFields = unknownFields.build(); - makeExtensionsImmutable(); - } - } - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_BackupFrame_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_BackupFrame_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame.class, org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame.Builder.class); - } - - public static com.google.protobuf.Parser<BackupFrame> PARSER = - new com.google.protobuf.AbstractParser<BackupFrame>() { - public BackupFrame parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return new BackupFrame(input, extensionRegistry); - } - }; - - @java.lang.Override - public com.google.protobuf.Parser<BackupFrame> getParserForType() { - return PARSER; - } - - private int bitField0_; - // optional .signal.Header header = 1; - public static final int HEADER_FIELD_NUMBER = 1; - private org.thoughtcrime.securesms.backup.BackupProtos.Header header_; - /** - * <code>optional .signal.Header header = 1;</code> - */ - public boolean hasHeader() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional .signal.Header header = 1;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.Header getHeader() { - return header_; - } - /** - * <code>optional .signal.Header header = 1;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.HeaderOrBuilder getHeaderOrBuilder() { - return header_; - } - - // optional .signal.SqlStatement statement = 2; - public static final int STATEMENT_FIELD_NUMBER = 2; - private org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement statement_; - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - public boolean hasStatement() { - return ((bitField0_ & 0x00000002) == 0x00000002); - } - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement getStatement() { - return statement_; - } - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatementOrBuilder getStatementOrBuilder() { - return statement_; - } - - // optional .signal.SharedPreference preference = 3; - public static final int PREFERENCE_FIELD_NUMBER = 3; - private org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference preference_; - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - public boolean hasPreference() { - return ((bitField0_ & 0x00000004) == 0x00000004); - } - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference getPreference() { - return preference_; - } - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SharedPreferenceOrBuilder getPreferenceOrBuilder() { - return preference_; - } - - // optional .signal.Attachment attachment = 4; - public static final int ATTACHMENT_FIELD_NUMBER = 4; - private org.thoughtcrime.securesms.backup.BackupProtos.Attachment attachment_; - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - public boolean hasAttachment() { - return ((bitField0_ & 0x00000008) == 0x00000008); - } - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.Attachment getAttachment() { - return attachment_; - } - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.AttachmentOrBuilder getAttachmentOrBuilder() { - return attachment_; - } - - // optional .signal.DatabaseVersion version = 5; - public static final int VERSION_FIELD_NUMBER = 5; - private org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion version_; - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - public boolean hasVersion() { - return ((bitField0_ & 0x00000010) == 0x00000010); - } - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion getVersion() { - return version_; - } - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersionOrBuilder getVersionOrBuilder() { - return version_; - } - - // optional bool end = 6; - public static final int END_FIELD_NUMBER = 6; - private boolean end_; - /** - * <code>optional bool end = 6;</code> - */ - public boolean hasEnd() { - return ((bitField0_ & 0x00000020) == 0x00000020); - } - /** - * <code>optional bool end = 6;</code> - */ - public boolean getEnd() { - return end_; - } - - // optional .signal.Avatar avatar = 7; - public static final int AVATAR_FIELD_NUMBER = 7; - private org.thoughtcrime.securesms.backup.BackupProtos.Avatar avatar_; - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - public boolean hasAvatar() { - return ((bitField0_ & 0x00000040) == 0x00000040); - } - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.Avatar getAvatar() { - return avatar_; - } - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.AvatarOrBuilder getAvatarOrBuilder() { - return avatar_; - } - - // optional .signal.Sticker sticker = 8; - public static final int STICKER_FIELD_NUMBER = 8; - private org.thoughtcrime.securesms.backup.BackupProtos.Sticker sticker_; - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - public boolean hasSticker() { - return ((bitField0_ & 0x00000080) == 0x00000080); - } - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.Sticker getSticker() { - return sticker_; - } - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.StickerOrBuilder getStickerOrBuilder() { - return sticker_; - } - - private void initFields() { - header_ = org.thoughtcrime.securesms.backup.BackupProtos.Header.getDefaultInstance(); - statement_ = org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.getDefaultInstance(); - preference_ = org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.getDefaultInstance(); - attachment_ = org.thoughtcrime.securesms.backup.BackupProtos.Attachment.getDefaultInstance(); - version_ = org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.getDefaultInstance(); - end_ = false; - avatar_ = org.thoughtcrime.securesms.backup.BackupProtos.Avatar.getDefaultInstance(); - sticker_ = org.thoughtcrime.securesms.backup.BackupProtos.Sticker.getDefaultInstance(); - } - private byte memoizedIsInitialized = -1; - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized != -1) return isInitialized == 1; - - memoizedIsInitialized = 1; - return true; - } - - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - getSerializedSize(); - if (((bitField0_ & 0x00000001) == 0x00000001)) { - output.writeMessage(1, header_); - } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - output.writeMessage(2, statement_); - } - if (((bitField0_ & 0x00000004) == 0x00000004)) { - output.writeMessage(3, preference_); - } - if (((bitField0_ & 0x00000008) == 0x00000008)) { - output.writeMessage(4, attachment_); - } - if (((bitField0_ & 0x00000010) == 0x00000010)) { - output.writeMessage(5, version_); - } - if (((bitField0_ & 0x00000020) == 0x00000020)) { - output.writeBool(6, end_); - } - if (((bitField0_ & 0x00000040) == 0x00000040)) { - output.writeMessage(7, avatar_); - } - if (((bitField0_ & 0x00000080) == 0x00000080)) { - output.writeMessage(8, sticker_); - } - getUnknownFields().writeTo(output); - } - - private int memoizedSerializedSize = -1; - public int getSerializedSize() { - int size = memoizedSerializedSize; - if (size != -1) return size; - - size = 0; - if (((bitField0_ & 0x00000001) == 0x00000001)) { - size += com.google.protobuf.CodedOutputStream - .computeMessageSize(1, header_); - } - if (((bitField0_ & 0x00000002) == 0x00000002)) { - size += com.google.protobuf.CodedOutputStream - .computeMessageSize(2, statement_); - } - if (((bitField0_ & 0x00000004) == 0x00000004)) { - size += com.google.protobuf.CodedOutputStream - .computeMessageSize(3, preference_); - } - if (((bitField0_ & 0x00000008) == 0x00000008)) { - size += com.google.protobuf.CodedOutputStream - .computeMessageSize(4, attachment_); - } - if (((bitField0_ & 0x00000010) == 0x00000010)) { - size += com.google.protobuf.CodedOutputStream - .computeMessageSize(5, version_); - } - if (((bitField0_ & 0x00000020) == 0x00000020)) { - size += com.google.protobuf.CodedOutputStream - .computeBoolSize(6, end_); - } - if (((bitField0_ & 0x00000040) == 0x00000040)) { - size += com.google.protobuf.CodedOutputStream - .computeMessageSize(7, avatar_); - } - if (((bitField0_ & 0x00000080) == 0x00000080)) { - size += com.google.protobuf.CodedOutputStream - .computeMessageSize(8, sticker_); - } - size += getUnknownFields().getSerializedSize(); - memoizedSerializedSize = size; - return size; - } - - private static final long serialVersionUID = 0L; - @java.lang.Override - protected java.lang.Object writeReplace() - throws java.io.ObjectStreamException { - return super.writeReplace(); - } - - public static org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame parseFrom( - com.google.protobuf.ByteString data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame parseFrom(byte[] data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame parseFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame parseDelimitedFrom(java.io.InputStream input) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseDelimitedFrom(input, extensionRegistry); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame parseFrom( - com.google.protobuf.CodedInputStream input) - throws java.io.IOException { - return PARSER.parseFrom(input); - } - public static org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - return PARSER.parseFrom(input, extensionRegistry); - } - - public static Builder newBuilder() { return Builder.create(); } - public Builder newBuilderForType() { return newBuilder(); } - public static Builder newBuilder(org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame prototype) { - return newBuilder().mergeFrom(prototype); - } - public Builder toBuilder() { return newBuilder(this); } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - Builder builder = new Builder(parent); - return builder; - } - /** - * Protobuf type {@code signal.BackupFrame} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder<Builder> - implements org.thoughtcrime.securesms.backup.BackupProtos.BackupFrameOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor - getDescriptor() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_BackupFrame_descriptor; - } - - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable - internalGetFieldAccessorTable() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_BackupFrame_fieldAccessorTable - .ensureFieldAccessorsInitialized( - org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame.class, org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame.Builder.class); - } - - // Construct using org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame.newBuilder() - private Builder() { - maybeForceBuilderInitialization(); - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent) { - super(parent); - maybeForceBuilderInitialization(); - } - private void maybeForceBuilderInitialization() { - if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { - getHeaderFieldBuilder(); - getStatementFieldBuilder(); - getPreferenceFieldBuilder(); - getAttachmentFieldBuilder(); - getVersionFieldBuilder(); - getAvatarFieldBuilder(); - getStickerFieldBuilder(); - } - } - private static Builder create() { - return new Builder(); - } - - public Builder clear() { - super.clear(); - if (headerBuilder_ == null) { - header_ = org.thoughtcrime.securesms.backup.BackupProtos.Header.getDefaultInstance(); - } else { - headerBuilder_.clear(); - } - bitField0_ = (bitField0_ & ~0x00000001); - if (statementBuilder_ == null) { - statement_ = org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.getDefaultInstance(); - } else { - statementBuilder_.clear(); - } - bitField0_ = (bitField0_ & ~0x00000002); - if (preferenceBuilder_ == null) { - preference_ = org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.getDefaultInstance(); - } else { - preferenceBuilder_.clear(); - } - bitField0_ = (bitField0_ & ~0x00000004); - if (attachmentBuilder_ == null) { - attachment_ = org.thoughtcrime.securesms.backup.BackupProtos.Attachment.getDefaultInstance(); - } else { - attachmentBuilder_.clear(); - } - bitField0_ = (bitField0_ & ~0x00000008); - if (versionBuilder_ == null) { - version_ = org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.getDefaultInstance(); - } else { - versionBuilder_.clear(); - } - bitField0_ = (bitField0_ & ~0x00000010); - end_ = false; - bitField0_ = (bitField0_ & ~0x00000020); - if (avatarBuilder_ == null) { - avatar_ = org.thoughtcrime.securesms.backup.BackupProtos.Avatar.getDefaultInstance(); - } else { - avatarBuilder_.clear(); - } - bitField0_ = (bitField0_ & ~0x00000040); - if (stickerBuilder_ == null) { - sticker_ = org.thoughtcrime.securesms.backup.BackupProtos.Sticker.getDefaultInstance(); - } else { - stickerBuilder_.clear(); - } - bitField0_ = (bitField0_ & ~0x00000080); - return this; - } - - public Builder clone() { - return create().mergeFrom(buildPartial()); - } - - public com.google.protobuf.Descriptors.Descriptor - getDescriptorForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.internal_static_signal_BackupFrame_descriptor; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame getDefaultInstanceForType() { - return org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame.getDefaultInstance(); - } - - public org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame build() { - org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - public org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame buildPartial() { - org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame result = new org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame(this); - int from_bitField0_ = bitField0_; - int to_bitField0_ = 0; - if (((from_bitField0_ & 0x00000001) == 0x00000001)) { - to_bitField0_ |= 0x00000001; - } - if (headerBuilder_ == null) { - result.header_ = header_; - } else { - result.header_ = headerBuilder_.build(); - } - if (((from_bitField0_ & 0x00000002) == 0x00000002)) { - to_bitField0_ |= 0x00000002; - } - if (statementBuilder_ == null) { - result.statement_ = statement_; - } else { - result.statement_ = statementBuilder_.build(); - } - if (((from_bitField0_ & 0x00000004) == 0x00000004)) { - to_bitField0_ |= 0x00000004; - } - if (preferenceBuilder_ == null) { - result.preference_ = preference_; - } else { - result.preference_ = preferenceBuilder_.build(); - } - if (((from_bitField0_ & 0x00000008) == 0x00000008)) { - to_bitField0_ |= 0x00000008; - } - if (attachmentBuilder_ == null) { - result.attachment_ = attachment_; - } else { - result.attachment_ = attachmentBuilder_.build(); - } - if (((from_bitField0_ & 0x00000010) == 0x00000010)) { - to_bitField0_ |= 0x00000010; - } - if (versionBuilder_ == null) { - result.version_ = version_; - } else { - result.version_ = versionBuilder_.build(); - } - if (((from_bitField0_ & 0x00000020) == 0x00000020)) { - to_bitField0_ |= 0x00000020; - } - result.end_ = end_; - if (((from_bitField0_ & 0x00000040) == 0x00000040)) { - to_bitField0_ |= 0x00000040; - } - if (avatarBuilder_ == null) { - result.avatar_ = avatar_; - } else { - result.avatar_ = avatarBuilder_.build(); - } - if (((from_bitField0_ & 0x00000080) == 0x00000080)) { - to_bitField0_ |= 0x00000080; - } - if (stickerBuilder_ == null) { - result.sticker_ = sticker_; - } else { - result.sticker_ = stickerBuilder_.build(); - } - result.bitField0_ = to_bitField0_; - onBuilt(); - return result; - } - - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame) { - return mergeFrom((org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame)other); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom(org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame other) { - if (other == org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame.getDefaultInstance()) return this; - if (other.hasHeader()) { - mergeHeader(other.getHeader()); - } - if (other.hasStatement()) { - mergeStatement(other.getStatement()); - } - if (other.hasPreference()) { - mergePreference(other.getPreference()); - } - if (other.hasAttachment()) { - mergeAttachment(other.getAttachment()); - } - if (other.hasVersion()) { - mergeVersion(other.getVersion()); - } - if (other.hasEnd()) { - setEnd(other.getEnd()); - } - if (other.hasAvatar()) { - mergeAvatar(other.getAvatar()); - } - if (other.hasSticker()) { - mergeSticker(other.getSticker()); - } - this.mergeUnknownFields(other.getUnknownFields()); - return this; - } - - public final boolean isInitialized() { - return true; - } - - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry) - throws java.io.IOException { - org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame parsedMessage = null; - try { - parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - parsedMessage = (org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame) e.getUnfinishedMessage(); - throw e; - } finally { - if (parsedMessage != null) { - mergeFrom(parsedMessage); - } - } - return this; - } - private int bitField0_; - - // optional .signal.Header header = 1; - private org.thoughtcrime.securesms.backup.BackupProtos.Header header_ = org.thoughtcrime.securesms.backup.BackupProtos.Header.getDefaultInstance(); - private com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.Header, org.thoughtcrime.securesms.backup.BackupProtos.Header.Builder, org.thoughtcrime.securesms.backup.BackupProtos.HeaderOrBuilder> headerBuilder_; - /** - * <code>optional .signal.Header header = 1;</code> - */ - public boolean hasHeader() { - return ((bitField0_ & 0x00000001) == 0x00000001); - } - /** - * <code>optional .signal.Header header = 1;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.Header getHeader() { - if (headerBuilder_ == null) { - return header_; - } else { - return headerBuilder_.getMessage(); - } - } - /** - * <code>optional .signal.Header header = 1;</code> - */ - public Builder setHeader(org.thoughtcrime.securesms.backup.BackupProtos.Header value) { - if (headerBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - header_ = value; - onChanged(); - } else { - headerBuilder_.setMessage(value); - } - bitField0_ |= 0x00000001; - return this; - } - /** - * <code>optional .signal.Header header = 1;</code> - */ - public Builder setHeader( - org.thoughtcrime.securesms.backup.BackupProtos.Header.Builder builderForValue) { - if (headerBuilder_ == null) { - header_ = builderForValue.build(); - onChanged(); - } else { - headerBuilder_.setMessage(builderForValue.build()); - } - bitField0_ |= 0x00000001; - return this; - } - /** - * <code>optional .signal.Header header = 1;</code> - */ - public Builder mergeHeader(org.thoughtcrime.securesms.backup.BackupProtos.Header value) { - if (headerBuilder_ == null) { - if (((bitField0_ & 0x00000001) == 0x00000001) && - header_ != org.thoughtcrime.securesms.backup.BackupProtos.Header.getDefaultInstance()) { - header_ = - org.thoughtcrime.securesms.backup.BackupProtos.Header.newBuilder(header_).mergeFrom(value).buildPartial(); - } else { - header_ = value; - } - onChanged(); - } else { - headerBuilder_.mergeFrom(value); - } - bitField0_ |= 0x00000001; - return this; - } - /** - * <code>optional .signal.Header header = 1;</code> - */ - public Builder clearHeader() { - if (headerBuilder_ == null) { - header_ = org.thoughtcrime.securesms.backup.BackupProtos.Header.getDefaultInstance(); - onChanged(); - } else { - headerBuilder_.clear(); - } - bitField0_ = (bitField0_ & ~0x00000001); - return this; - } - /** - * <code>optional .signal.Header header = 1;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.Header.Builder getHeaderBuilder() { - bitField0_ |= 0x00000001; - onChanged(); - return getHeaderFieldBuilder().getBuilder(); - } - /** - * <code>optional .signal.Header header = 1;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.HeaderOrBuilder getHeaderOrBuilder() { - if (headerBuilder_ != null) { - return headerBuilder_.getMessageOrBuilder(); - } else { - return header_; - } - } - /** - * <code>optional .signal.Header header = 1;</code> - */ - private com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.Header, org.thoughtcrime.securesms.backup.BackupProtos.Header.Builder, org.thoughtcrime.securesms.backup.BackupProtos.HeaderOrBuilder> - getHeaderFieldBuilder() { - if (headerBuilder_ == null) { - headerBuilder_ = new com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.Header, org.thoughtcrime.securesms.backup.BackupProtos.Header.Builder, org.thoughtcrime.securesms.backup.BackupProtos.HeaderOrBuilder>( - header_, - getParentForChildren(), - isClean()); - header_ = null; - } - return headerBuilder_; - } - - // optional .signal.SqlStatement statement = 2; - private org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement statement_ = org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.getDefaultInstance(); - private com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.Builder, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatementOrBuilder> statementBuilder_; - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - public boolean hasStatement() { - return ((bitField0_ & 0x00000002) == 0x00000002); - } - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement getStatement() { - if (statementBuilder_ == null) { - return statement_; - } else { - return statementBuilder_.getMessage(); - } - } - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - public Builder setStatement(org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement value) { - if (statementBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - statement_ = value; - onChanged(); - } else { - statementBuilder_.setMessage(value); - } - bitField0_ |= 0x00000002; - return this; - } - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - public Builder setStatement( - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.Builder builderForValue) { - if (statementBuilder_ == null) { - statement_ = builderForValue.build(); - onChanged(); - } else { - statementBuilder_.setMessage(builderForValue.build()); - } - bitField0_ |= 0x00000002; - return this; - } - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - public Builder mergeStatement(org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement value) { - if (statementBuilder_ == null) { - if (((bitField0_ & 0x00000002) == 0x00000002) && - statement_ != org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.getDefaultInstance()) { - statement_ = - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.newBuilder(statement_).mergeFrom(value).buildPartial(); - } else { - statement_ = value; - } - onChanged(); - } else { - statementBuilder_.mergeFrom(value); - } - bitField0_ |= 0x00000002; - return this; - } - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - public Builder clearStatement() { - if (statementBuilder_ == null) { - statement_ = org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.getDefaultInstance(); - onChanged(); - } else { - statementBuilder_.clear(); - } - bitField0_ = (bitField0_ & ~0x00000002); - return this; - } - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.Builder getStatementBuilder() { - bitField0_ |= 0x00000002; - onChanged(); - return getStatementFieldBuilder().getBuilder(); - } - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SqlStatementOrBuilder getStatementOrBuilder() { - if (statementBuilder_ != null) { - return statementBuilder_.getMessageOrBuilder(); - } else { - return statement_; - } - } - /** - * <code>optional .signal.SqlStatement statement = 2;</code> - */ - private com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.Builder, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatementOrBuilder> - getStatementFieldBuilder() { - if (statementBuilder_ == null) { - statementBuilder_ = new com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement.Builder, org.thoughtcrime.securesms.backup.BackupProtos.SqlStatementOrBuilder>( - statement_, - getParentForChildren(), - isClean()); - statement_ = null; - } - return statementBuilder_; - } - - // optional .signal.SharedPreference preference = 3; - private org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference preference_ = org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.getDefaultInstance(); - private com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference, org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.Builder, org.thoughtcrime.securesms.backup.BackupProtos.SharedPreferenceOrBuilder> preferenceBuilder_; - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - public boolean hasPreference() { - return ((bitField0_ & 0x00000004) == 0x00000004); - } - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference getPreference() { - if (preferenceBuilder_ == null) { - return preference_; - } else { - return preferenceBuilder_.getMessage(); - } - } - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - public Builder setPreference(org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference value) { - if (preferenceBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - preference_ = value; - onChanged(); - } else { - preferenceBuilder_.setMessage(value); - } - bitField0_ |= 0x00000004; - return this; - } - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - public Builder setPreference( - org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.Builder builderForValue) { - if (preferenceBuilder_ == null) { - preference_ = builderForValue.build(); - onChanged(); - } else { - preferenceBuilder_.setMessage(builderForValue.build()); - } - bitField0_ |= 0x00000004; - return this; - } - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - public Builder mergePreference(org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference value) { - if (preferenceBuilder_ == null) { - if (((bitField0_ & 0x00000004) == 0x00000004) && - preference_ != org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.getDefaultInstance()) { - preference_ = - org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.newBuilder(preference_).mergeFrom(value).buildPartial(); - } else { - preference_ = value; - } - onChanged(); - } else { - preferenceBuilder_.mergeFrom(value); - } - bitField0_ |= 0x00000004; - return this; - } - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - public Builder clearPreference() { - if (preferenceBuilder_ == null) { - preference_ = org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.getDefaultInstance(); - onChanged(); - } else { - preferenceBuilder_.clear(); - } - bitField0_ = (bitField0_ & ~0x00000004); - return this; - } - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.Builder getPreferenceBuilder() { - bitField0_ |= 0x00000004; - onChanged(); - return getPreferenceFieldBuilder().getBuilder(); - } - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.SharedPreferenceOrBuilder getPreferenceOrBuilder() { - if (preferenceBuilder_ != null) { - return preferenceBuilder_.getMessageOrBuilder(); - } else { - return preference_; - } - } - /** - * <code>optional .signal.SharedPreference preference = 3;</code> - */ - private com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference, org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.Builder, org.thoughtcrime.securesms.backup.BackupProtos.SharedPreferenceOrBuilder> - getPreferenceFieldBuilder() { - if (preferenceBuilder_ == null) { - preferenceBuilder_ = new com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference, org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference.Builder, org.thoughtcrime.securesms.backup.BackupProtos.SharedPreferenceOrBuilder>( - preference_, - getParentForChildren(), - isClean()); - preference_ = null; - } - return preferenceBuilder_; - } - - // optional .signal.Attachment attachment = 4; - private org.thoughtcrime.securesms.backup.BackupProtos.Attachment attachment_ = org.thoughtcrime.securesms.backup.BackupProtos.Attachment.getDefaultInstance(); - private com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.Attachment, org.thoughtcrime.securesms.backup.BackupProtos.Attachment.Builder, org.thoughtcrime.securesms.backup.BackupProtos.AttachmentOrBuilder> attachmentBuilder_; - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - public boolean hasAttachment() { - return ((bitField0_ & 0x00000008) == 0x00000008); - } - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.Attachment getAttachment() { - if (attachmentBuilder_ == null) { - return attachment_; - } else { - return attachmentBuilder_.getMessage(); - } - } - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - public Builder setAttachment(org.thoughtcrime.securesms.backup.BackupProtos.Attachment value) { - if (attachmentBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - attachment_ = value; - onChanged(); - } else { - attachmentBuilder_.setMessage(value); - } - bitField0_ |= 0x00000008; - return this; - } - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - public Builder setAttachment( - org.thoughtcrime.securesms.backup.BackupProtos.Attachment.Builder builderForValue) { - if (attachmentBuilder_ == null) { - attachment_ = builderForValue.build(); - onChanged(); - } else { - attachmentBuilder_.setMessage(builderForValue.build()); - } - bitField0_ |= 0x00000008; - return this; - } - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - public Builder mergeAttachment(org.thoughtcrime.securesms.backup.BackupProtos.Attachment value) { - if (attachmentBuilder_ == null) { - if (((bitField0_ & 0x00000008) == 0x00000008) && - attachment_ != org.thoughtcrime.securesms.backup.BackupProtos.Attachment.getDefaultInstance()) { - attachment_ = - org.thoughtcrime.securesms.backup.BackupProtos.Attachment.newBuilder(attachment_).mergeFrom(value).buildPartial(); - } else { - attachment_ = value; - } - onChanged(); - } else { - attachmentBuilder_.mergeFrom(value); - } - bitField0_ |= 0x00000008; - return this; - } - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - public Builder clearAttachment() { - if (attachmentBuilder_ == null) { - attachment_ = org.thoughtcrime.securesms.backup.BackupProtos.Attachment.getDefaultInstance(); - onChanged(); - } else { - attachmentBuilder_.clear(); - } - bitField0_ = (bitField0_ & ~0x00000008); - return this; - } - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.Attachment.Builder getAttachmentBuilder() { - bitField0_ |= 0x00000008; - onChanged(); - return getAttachmentFieldBuilder().getBuilder(); - } - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.AttachmentOrBuilder getAttachmentOrBuilder() { - if (attachmentBuilder_ != null) { - return attachmentBuilder_.getMessageOrBuilder(); - } else { - return attachment_; - } - } - /** - * <code>optional .signal.Attachment attachment = 4;</code> - */ - private com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.Attachment, org.thoughtcrime.securesms.backup.BackupProtos.Attachment.Builder, org.thoughtcrime.securesms.backup.BackupProtos.AttachmentOrBuilder> - getAttachmentFieldBuilder() { - if (attachmentBuilder_ == null) { - attachmentBuilder_ = new com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.Attachment, org.thoughtcrime.securesms.backup.BackupProtos.Attachment.Builder, org.thoughtcrime.securesms.backup.BackupProtos.AttachmentOrBuilder>( - attachment_, - getParentForChildren(), - isClean()); - attachment_ = null; - } - return attachmentBuilder_; - } - - // optional .signal.DatabaseVersion version = 5; - private org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion version_ = org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.getDefaultInstance(); - private com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion, org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.Builder, org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersionOrBuilder> versionBuilder_; - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - public boolean hasVersion() { - return ((bitField0_ & 0x00000010) == 0x00000010); - } - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion getVersion() { - if (versionBuilder_ == null) { - return version_; - } else { - return versionBuilder_.getMessage(); - } - } - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - public Builder setVersion(org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion value) { - if (versionBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - version_ = value; - onChanged(); - } else { - versionBuilder_.setMessage(value); - } - bitField0_ |= 0x00000010; - return this; - } - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - public Builder setVersion( - org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.Builder builderForValue) { - if (versionBuilder_ == null) { - version_ = builderForValue.build(); - onChanged(); - } else { - versionBuilder_.setMessage(builderForValue.build()); - } - bitField0_ |= 0x00000010; - return this; - } - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - public Builder mergeVersion(org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion value) { - if (versionBuilder_ == null) { - if (((bitField0_ & 0x00000010) == 0x00000010) && - version_ != org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.getDefaultInstance()) { - version_ = - org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.newBuilder(version_).mergeFrom(value).buildPartial(); - } else { - version_ = value; - } - onChanged(); - } else { - versionBuilder_.mergeFrom(value); - } - bitField0_ |= 0x00000010; - return this; - } - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - public Builder clearVersion() { - if (versionBuilder_ == null) { - version_ = org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.getDefaultInstance(); - onChanged(); - } else { - versionBuilder_.clear(); - } - bitField0_ = (bitField0_ & ~0x00000010); - return this; - } - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.Builder getVersionBuilder() { - bitField0_ |= 0x00000010; - onChanged(); - return getVersionFieldBuilder().getBuilder(); - } - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersionOrBuilder getVersionOrBuilder() { - if (versionBuilder_ != null) { - return versionBuilder_.getMessageOrBuilder(); - } else { - return version_; - } - } - /** - * <code>optional .signal.DatabaseVersion version = 5;</code> - */ - private com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion, org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.Builder, org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersionOrBuilder> - getVersionFieldBuilder() { - if (versionBuilder_ == null) { - versionBuilder_ = new com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion, org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion.Builder, org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersionOrBuilder>( - version_, - getParentForChildren(), - isClean()); - version_ = null; - } - return versionBuilder_; - } - - // optional bool end = 6; - private boolean end_ ; - /** - * <code>optional bool end = 6;</code> - */ - public boolean hasEnd() { - return ((bitField0_ & 0x00000020) == 0x00000020); - } - /** - * <code>optional bool end = 6;</code> - */ - public boolean getEnd() { - return end_; - } - /** - * <code>optional bool end = 6;</code> - */ - public Builder setEnd(boolean value) { - bitField0_ |= 0x00000020; - end_ = value; - onChanged(); - return this; - } - /** - * <code>optional bool end = 6;</code> - */ - public Builder clearEnd() { - bitField0_ = (bitField0_ & ~0x00000020); - end_ = false; - onChanged(); - return this; - } - - // optional .signal.Avatar avatar = 7; - private org.thoughtcrime.securesms.backup.BackupProtos.Avatar avatar_ = org.thoughtcrime.securesms.backup.BackupProtos.Avatar.getDefaultInstance(); - private com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.Avatar, org.thoughtcrime.securesms.backup.BackupProtos.Avatar.Builder, org.thoughtcrime.securesms.backup.BackupProtos.AvatarOrBuilder> avatarBuilder_; - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - public boolean hasAvatar() { - return ((bitField0_ & 0x00000040) == 0x00000040); - } - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.Avatar getAvatar() { - if (avatarBuilder_ == null) { - return avatar_; - } else { - return avatarBuilder_.getMessage(); - } - } - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - public Builder setAvatar(org.thoughtcrime.securesms.backup.BackupProtos.Avatar value) { - if (avatarBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - avatar_ = value; - onChanged(); - } else { - avatarBuilder_.setMessage(value); - } - bitField0_ |= 0x00000040; - return this; - } - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - public Builder setAvatar( - org.thoughtcrime.securesms.backup.BackupProtos.Avatar.Builder builderForValue) { - if (avatarBuilder_ == null) { - avatar_ = builderForValue.build(); - onChanged(); - } else { - avatarBuilder_.setMessage(builderForValue.build()); - } - bitField0_ |= 0x00000040; - return this; - } - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - public Builder mergeAvatar(org.thoughtcrime.securesms.backup.BackupProtos.Avatar value) { - if (avatarBuilder_ == null) { - if (((bitField0_ & 0x00000040) == 0x00000040) && - avatar_ != org.thoughtcrime.securesms.backup.BackupProtos.Avatar.getDefaultInstance()) { - avatar_ = - org.thoughtcrime.securesms.backup.BackupProtos.Avatar.newBuilder(avatar_).mergeFrom(value).buildPartial(); - } else { - avatar_ = value; - } - onChanged(); - } else { - avatarBuilder_.mergeFrom(value); - } - bitField0_ |= 0x00000040; - return this; - } - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - public Builder clearAvatar() { - if (avatarBuilder_ == null) { - avatar_ = org.thoughtcrime.securesms.backup.BackupProtos.Avatar.getDefaultInstance(); - onChanged(); - } else { - avatarBuilder_.clear(); - } - bitField0_ = (bitField0_ & ~0x00000040); - return this; - } - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.Avatar.Builder getAvatarBuilder() { - bitField0_ |= 0x00000040; - onChanged(); - return getAvatarFieldBuilder().getBuilder(); - } - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.AvatarOrBuilder getAvatarOrBuilder() { - if (avatarBuilder_ != null) { - return avatarBuilder_.getMessageOrBuilder(); - } else { - return avatar_; - } - } - /** - * <code>optional .signal.Avatar avatar = 7;</code> - */ - private com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.Avatar, org.thoughtcrime.securesms.backup.BackupProtos.Avatar.Builder, org.thoughtcrime.securesms.backup.BackupProtos.AvatarOrBuilder> - getAvatarFieldBuilder() { - if (avatarBuilder_ == null) { - avatarBuilder_ = new com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.Avatar, org.thoughtcrime.securesms.backup.BackupProtos.Avatar.Builder, org.thoughtcrime.securesms.backup.BackupProtos.AvatarOrBuilder>( - avatar_, - getParentForChildren(), - isClean()); - avatar_ = null; - } - return avatarBuilder_; - } - - // optional .signal.Sticker sticker = 8; - private org.thoughtcrime.securesms.backup.BackupProtos.Sticker sticker_ = org.thoughtcrime.securesms.backup.BackupProtos.Sticker.getDefaultInstance(); - private com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.Sticker, org.thoughtcrime.securesms.backup.BackupProtos.Sticker.Builder, org.thoughtcrime.securesms.backup.BackupProtos.StickerOrBuilder> stickerBuilder_; - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - public boolean hasSticker() { - return ((bitField0_ & 0x00000080) == 0x00000080); - } - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.Sticker getSticker() { - if (stickerBuilder_ == null) { - return sticker_; - } else { - return stickerBuilder_.getMessage(); - } - } - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - public Builder setSticker(org.thoughtcrime.securesms.backup.BackupProtos.Sticker value) { - if (stickerBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - sticker_ = value; - onChanged(); - } else { - stickerBuilder_.setMessage(value); - } - bitField0_ |= 0x00000080; - return this; - } - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - public Builder setSticker( - org.thoughtcrime.securesms.backup.BackupProtos.Sticker.Builder builderForValue) { - if (stickerBuilder_ == null) { - sticker_ = builderForValue.build(); - onChanged(); - } else { - stickerBuilder_.setMessage(builderForValue.build()); - } - bitField0_ |= 0x00000080; - return this; - } - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - public Builder mergeSticker(org.thoughtcrime.securesms.backup.BackupProtos.Sticker value) { - if (stickerBuilder_ == null) { - if (((bitField0_ & 0x00000080) == 0x00000080) && - sticker_ != org.thoughtcrime.securesms.backup.BackupProtos.Sticker.getDefaultInstance()) { - sticker_ = - org.thoughtcrime.securesms.backup.BackupProtos.Sticker.newBuilder(sticker_).mergeFrom(value).buildPartial(); - } else { - sticker_ = value; - } - onChanged(); - } else { - stickerBuilder_.mergeFrom(value); - } - bitField0_ |= 0x00000080; - return this; - } - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - public Builder clearSticker() { - if (stickerBuilder_ == null) { - sticker_ = org.thoughtcrime.securesms.backup.BackupProtos.Sticker.getDefaultInstance(); - onChanged(); - } else { - stickerBuilder_.clear(); - } - bitField0_ = (bitField0_ & ~0x00000080); - return this; - } - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.Sticker.Builder getStickerBuilder() { - bitField0_ |= 0x00000080; - onChanged(); - return getStickerFieldBuilder().getBuilder(); - } - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - public org.thoughtcrime.securesms.backup.BackupProtos.StickerOrBuilder getStickerOrBuilder() { - if (stickerBuilder_ != null) { - return stickerBuilder_.getMessageOrBuilder(); - } else { - return sticker_; - } - } - /** - * <code>optional .signal.Sticker sticker = 8;</code> - */ - private com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.Sticker, org.thoughtcrime.securesms.backup.BackupProtos.Sticker.Builder, org.thoughtcrime.securesms.backup.BackupProtos.StickerOrBuilder> - getStickerFieldBuilder() { - if (stickerBuilder_ == null) { - stickerBuilder_ = new com.google.protobuf.SingleFieldBuilder< - org.thoughtcrime.securesms.backup.BackupProtos.Sticker, org.thoughtcrime.securesms.backup.BackupProtos.Sticker.Builder, org.thoughtcrime.securesms.backup.BackupProtos.StickerOrBuilder>( - sticker_, - getParentForChildren(), - isClean()); - sticker_ = null; - } - return stickerBuilder_; - } - - // @@protoc_insertion_point(builder_scope:signal.BackupFrame) - } - - static { - defaultInstance = new BackupFrame(true); - defaultInstance.initFields(); - } - - // @@protoc_insertion_point(class_scope:signal.BackupFrame) - } - - private static com.google.protobuf.Descriptors.Descriptor - internal_static_signal_SqlStatement_descriptor; - private static - com.google.protobuf.GeneratedMessage.FieldAccessorTable - internal_static_signal_SqlStatement_fieldAccessorTable; - private static com.google.protobuf.Descriptors.Descriptor - internal_static_signal_SqlStatement_SqlParameter_descriptor; - private static - com.google.protobuf.GeneratedMessage.FieldAccessorTable - internal_static_signal_SqlStatement_SqlParameter_fieldAccessorTable; - private static com.google.protobuf.Descriptors.Descriptor - internal_static_signal_SharedPreference_descriptor; - private static - com.google.protobuf.GeneratedMessage.FieldAccessorTable - internal_static_signal_SharedPreference_fieldAccessorTable; - private static com.google.protobuf.Descriptors.Descriptor - internal_static_signal_Attachment_descriptor; - private static - com.google.protobuf.GeneratedMessage.FieldAccessorTable - internal_static_signal_Attachment_fieldAccessorTable; - private static com.google.protobuf.Descriptors.Descriptor - internal_static_signal_Sticker_descriptor; - private static - com.google.protobuf.GeneratedMessage.FieldAccessorTable - internal_static_signal_Sticker_fieldAccessorTable; - private static com.google.protobuf.Descriptors.Descriptor - internal_static_signal_Avatar_descriptor; - private static - com.google.protobuf.GeneratedMessage.FieldAccessorTable - internal_static_signal_Avatar_fieldAccessorTable; - private static com.google.protobuf.Descriptors.Descriptor - internal_static_signal_DatabaseVersion_descriptor; - private static - com.google.protobuf.GeneratedMessage.FieldAccessorTable - internal_static_signal_DatabaseVersion_fieldAccessorTable; - private static com.google.protobuf.Descriptors.Descriptor - internal_static_signal_Header_descriptor; - private static - com.google.protobuf.GeneratedMessage.FieldAccessorTable - internal_static_signal_Header_fieldAccessorTable; - private static com.google.protobuf.Descriptors.Descriptor - internal_static_signal_BackupFrame_descriptor; - private static - com.google.protobuf.GeneratedMessage.FieldAccessorTable - internal_static_signal_BackupFrame_fieldAccessorTable; - - public static com.google.protobuf.Descriptors.FileDescriptor - getDescriptor() { - return descriptor; - } - private static com.google.protobuf.Descriptors.FileDescriptor - descriptor; - static { - java.lang.String[] descriptorData = { - "\n\rBackups.proto\022\006signal\"\342\001\n\014SqlStatement" + - "\022\021\n\tstatement\030\001 \001(\t\0225\n\nparameters\030\002 \003(\0132" + - "!.signal.SqlStatement.SqlParameter\032\207\001\n\014S" + - "qlParameter\022\026\n\016stringParamter\030\001 \001(\t\022\030\n\020i" + - "ntegerParameter\030\002 \001(\004\022\027\n\017doubleParameter" + - "\030\003 \001(\001\022\025\n\rblobParameter\030\004 \001(\014\022\025\n\rnullpar" + - "ameter\030\005 \001(\010\"<\n\020SharedPreference\022\014\n\004file" + - "\030\001 \001(\t\022\013\n\003key\030\002 \001(\t\022\r\n\005value\030\003 \001(\t\"A\n\nAt" + - "tachment\022\r\n\005rowId\030\001 \001(\004\022\024\n\014attachmentId\030" + - "\002 \001(\004\022\016\n\006length\030\003 \001(\r\"(\n\007Sticker\022\r\n\005rowI", - "d\030\001 \001(\004\022\016\n\006length\030\002 \001(\r\"&\n\006Avatar\022\014\n\004nam" + - "e\030\001 \001(\t\022\016\n\006length\030\002 \001(\r\"\"\n\017DatabaseVersi" + - "on\022\017\n\007version\030\001 \001(\r\"\"\n\006Header\022\n\n\002iv\030\001 \001(" + - "\014\022\014\n\004salt\030\002 \001(\014\"\245\002\n\013BackupFrame\022\036\n\006heade" + - "r\030\001 \001(\0132\016.signal.Header\022\'\n\tstatement\030\002 \001" + - "(\0132\024.signal.SqlStatement\022,\n\npreference\030\003" + - " \001(\0132\030.signal.SharedPreference\022&\n\nattach" + - "ment\030\004 \001(\0132\022.signal.Attachment\022(\n\007versio" + - "n\030\005 \001(\0132\027.signal.DatabaseVersion\022\013\n\003end\030" + - "\006 \001(\010\022\036\n\006avatar\030\007 \001(\0132\016.signal.Avatar\022 \n", - "\007sticker\030\010 \001(\0132\017.signal.StickerB1\n!org.t" + - "houghtcrime.securesms.backupB\014BackupProt" + - "os" - }; - com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner assigner = - new com.google.protobuf.Descriptors.FileDescriptor.InternalDescriptorAssigner() { - public com.google.protobuf.ExtensionRegistry assignDescriptors( - com.google.protobuf.Descriptors.FileDescriptor root) { - descriptor = root; - internal_static_signal_SqlStatement_descriptor = - getDescriptor().getMessageTypes().get(0); - internal_static_signal_SqlStatement_fieldAccessorTable = new - com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_signal_SqlStatement_descriptor, - new java.lang.String[] { "Statement", "Parameters", }); - internal_static_signal_SqlStatement_SqlParameter_descriptor = - internal_static_signal_SqlStatement_descriptor.getNestedTypes().get(0); - internal_static_signal_SqlStatement_SqlParameter_fieldAccessorTable = new - com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_signal_SqlStatement_SqlParameter_descriptor, - new java.lang.String[] { "StringParamter", "IntegerParameter", "DoubleParameter", "BlobParameter", "Nullparameter", }); - internal_static_signal_SharedPreference_descriptor = - getDescriptor().getMessageTypes().get(1); - internal_static_signal_SharedPreference_fieldAccessorTable = new - com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_signal_SharedPreference_descriptor, - new java.lang.String[] { "File", "Key", "Value", }); - internal_static_signal_Attachment_descriptor = - getDescriptor().getMessageTypes().get(2); - internal_static_signal_Attachment_fieldAccessorTable = new - com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_signal_Attachment_descriptor, - new java.lang.String[] { "RowId", "AttachmentId", "Length", }); - internal_static_signal_Sticker_descriptor = - getDescriptor().getMessageTypes().get(3); - internal_static_signal_Sticker_fieldAccessorTable = new - com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_signal_Sticker_descriptor, - new java.lang.String[] { "RowId", "Length", }); - internal_static_signal_Avatar_descriptor = - getDescriptor().getMessageTypes().get(4); - internal_static_signal_Avatar_fieldAccessorTable = new - com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_signal_Avatar_descriptor, - new java.lang.String[] { "Name", "Length", }); - internal_static_signal_DatabaseVersion_descriptor = - getDescriptor().getMessageTypes().get(5); - internal_static_signal_DatabaseVersion_fieldAccessorTable = new - com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_signal_DatabaseVersion_descriptor, - new java.lang.String[] { "Version", }); - internal_static_signal_Header_descriptor = - getDescriptor().getMessageTypes().get(6); - internal_static_signal_Header_fieldAccessorTable = new - com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_signal_Header_descriptor, - new java.lang.String[] { "Iv", "Salt", }); - internal_static_signal_BackupFrame_descriptor = - getDescriptor().getMessageTypes().get(7); - internal_static_signal_BackupFrame_fieldAccessorTable = new - com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_signal_BackupFrame_descriptor, - new java.lang.String[] { "Header", "Statement", "Preference", "Attachment", "Version", "End", "Avatar", "Sticker", }); - return null; - } - }; - com.google.protobuf.Descriptors.FileDescriptor - .internalBuildGeneratedFileFrom(descriptorData, - new com.google.protobuf.Descriptors.FileDescriptor[] { - }, assigner); - } - - // @@protoc_insertion_point(outer_class_scope) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupRestoreActivity.kt deleted file mode 100644 index a94c866c09..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupRestoreActivity.kt +++ /dev/null @@ -1,206 +0,0 @@ -package org.thoughtcrime.securesms.backup - -import android.app.Activity -import android.app.Application -import android.content.Intent -import android.graphics.Typeface -import android.net.Uri -import android.os.Bundle -import android.provider.OpenableColumns -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.style.ClickableSpan -import android.text.style.StyleSpan -import android.view.View -import android.widget.Toast -import androidx.activity.result.ActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.viewModelScope -import com.google.android.gms.common.util.Strings -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.libsignal.utilities.Log -import org.thoughtcrime.securesms.BaseActionBarActivity -import org.thoughtcrime.securesms.backup.FullBackupImporter.DatabaseDowngradeException -import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider -import org.thoughtcrime.securesms.database.DatabaseFactory -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.home.HomeActivity -import org.thoughtcrime.securesms.notifications.NotificationChannels -import org.thoughtcrime.securesms.util.BackupUtil -import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo -import org.thoughtcrime.securesms.util.show - -class BackupRestoreActivity : BaseActionBarActivity() { - - companion object { - private const val TAG = "BackupRestoreActivity" - } - - private val viewModel by viewModels<BackupRestoreViewModel>() - - private val fileSelectionResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult() - ) { result: ActivityResult -> - if (result.resultCode == Activity.RESULT_OK && result.data != null && result.data!!.data != null) { - viewModel.backupFile.value = result.data!!.data!! - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setUpActionBarSessionLogo() - -// val viewBinding = DataBindingUtil.setContentView<ActivityBackupRestoreBinding>(this, R.layout.activity_backup_restore) -// viewBinding.lifecycleOwner = this -// viewBinding.viewModel = viewModel - -// viewBinding.restoreButton.setOnClickListener { viewModel.tryRestoreBackup() } - -// viewBinding.buttonSelectFile.setOnClickListener { -// fileSelectionResultLauncher.launch(Intent(Intent.ACTION_OPEN_DOCUMENT).apply { -// //FIXME On some old APIs (tested on 21 & 23) the mime type doesn't filter properly -// // and the backup files are unavailable for selection. -//// type = BackupUtil.BACKUP_FILE_MIME_TYPE -// type = "*/*" -// }) -// } - -// viewBinding.backupCode.addTextChangedListener { text -> viewModel.backupPassphrase.value = text.toString() } - - // Focus passphrase text edit when backup file is selected. -// viewModel.backupFile.observe(this, { backupFile -> -// if (backupFile != null) viewBinding.backupCode.post { -// viewBinding.backupCode.requestFocus() -// (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager) -// .showSoftInput(viewBinding.backupCode, InputMethodManager.SHOW_IMPLICIT) -// } -// }) - - // React to backup import result. - viewModel.backupImportResult.observe(this) { result -> - if (result != null) when (result) { - BackupRestoreViewModel.BackupRestoreResult.SUCCESS -> { - val intent = Intent(this, HomeActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - this.show(intent) - } - BackupRestoreViewModel.BackupRestoreResult.FAILURE_VERSION_DOWNGRADE -> - Toast.makeText(this, R.string.RegistrationActivity_backup_failure_downgrade, Toast.LENGTH_LONG).show() - BackupRestoreViewModel.BackupRestoreResult.FAILURE_UNKNOWN -> - Toast.makeText(this, R.string.RegistrationActivity_incorrect_backup_passphrase, Toast.LENGTH_LONG).show() - } - } - - //region Legal info views - 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) -// viewBinding.termsTextView.movementMethod = LinkMovementMethod.getInstance() -// viewBinding.termsTextView.text = termsExplanation - //endregion - } - - 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() - } - } -} - -class BackupRestoreViewModel(application: Application): AndroidViewModel(application) { - - companion object { - private const val TAG = "BackupRestoreViewModel" - - @JvmStatic - fun uriToFileName(view: View, fileUri: Uri?): String? { - fileUri ?: return null - - view.context.contentResolver.query(fileUri, null, null, null, null).use { - val nameIndex = it!!.getColumnIndex(OpenableColumns.DISPLAY_NAME) - it.moveToFirst() - return it.getString(nameIndex) - } - } - - @JvmStatic - fun validateData(fileUri: Uri?, passphrase: String?): Boolean { - return fileUri != null && - !Strings.isEmptyOrWhitespace(passphrase) && - passphrase!!.length == BackupUtil.BACKUP_PASSPHRASE_LENGTH - } - } - - val backupFile = MutableLiveData<Uri>(null) - val backupPassphrase = MutableLiveData<String>(null) - - val processingBackupFile = MutableLiveData<Boolean>(false) - val backupImportResult = MutableLiveData<BackupRestoreResult>(null) - - fun tryRestoreBackup() = viewModelScope.launch { - if (processingBackupFile.value == true) return@launch - if (backupImportResult.value == BackupRestoreResult.SUCCESS) return@launch - if (!validateData(backupFile.value, backupPassphrase.value)) return@launch - - val context = getApplication<Application>() - val backupFile = backupFile.value!! - val passphrase = backupPassphrase.value!! - - val result: BackupRestoreResult - - processingBackupFile.value = true - - withContext(Dispatchers.IO) { - result = try { - val database = DatabaseComponent.get(context).openHelper().readableDatabase - FullBackupImporter.importFromUri( - context, - AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), - database, - backupFile, - passphrase - ) - DatabaseFactory.upgradeRestored(context, database) - NotificationChannels.restoreContactNotificationChannels(context) - TextSecurePreferences.setRestorationTime(context, System.currentTimeMillis()) - TextSecurePreferences.setHasViewedSeed(context, true) - TextSecurePreferences.setHasSeenWelcomeScreen(context, true) - - BackupRestoreResult.SUCCESS - } catch (e: DatabaseDowngradeException) { - Log.w(TAG, "Failed due to the backup being from a newer version of Signal.", e) - BackupRestoreResult.FAILURE_VERSION_DOWNGRADE - } catch (e: Exception) { - Log.w(TAG, e) - BackupRestoreResult.FAILURE_UNKNOWN - } - } - - processingBackupFile.value = false - - backupImportResult.value = result - } - - enum class BackupRestoreResult { - SUCCESS, FAILURE_VERSION_DOWNGRADE, FAILURE_UNKNOWN - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.kt deleted file mode 100644 index 33b8b67258..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.kt +++ /dev/null @@ -1,447 +0,0 @@ -package org.thoughtcrime.securesms.backup - -import android.content.Context -import android.database.Cursor -import android.net.Uri -import android.text.TextUtils -import androidx.annotation.WorkerThread -import com.annimon.stream.function.Consumer -import com.annimon.stream.function.Predicate -import com.google.protobuf.ByteString -import net.sqlcipher.database.SQLiteDatabase -import org.greenrobot.eventbus.EventBus -import org.session.libsession.avatars.AvatarHelper -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId -import org.session.libsession.utilities.Conversions -import org.session.libsession.utilities.Util -import org.session.libsignal.crypto.kdf.HKDFv3 -import org.session.libsignal.utilities.ByteUtil -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.backup.BackupProtos.Attachment -import org.thoughtcrime.securesms.backup.BackupProtos.Avatar -import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame -import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion -import org.thoughtcrime.securesms.backup.BackupProtos.Header -import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference -import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement -import org.thoughtcrime.securesms.backup.BackupProtos.Sticker -import org.thoughtcrime.securesms.crypto.AttachmentSecret -import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream -import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream -import org.thoughtcrime.securesms.database.AttachmentDatabase -import org.thoughtcrime.securesms.database.GroupReceiptDatabase -import org.thoughtcrime.securesms.database.JobDatabase -import org.thoughtcrime.securesms.database.LokiAPIDatabase -import org.thoughtcrime.securesms.database.LokiBackupFilesDatabase -import org.thoughtcrime.securesms.database.MmsDatabase -import org.thoughtcrime.securesms.database.MmsSmsColumns -import org.thoughtcrime.securesms.database.PushDatabase -import org.thoughtcrime.securesms.database.SearchDatabase -import org.thoughtcrime.securesms.database.SmsDatabase -import org.thoughtcrime.securesms.util.BackupUtil -import java.io.Closeable -import java.io.File -import java.io.FileInputStream -import java.io.Flushable -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.security.InvalidAlgorithmParameterException -import java.security.InvalidKeyException -import java.security.NoSuchAlgorithmException -import java.util.LinkedList -import javax.crypto.BadPaddingException -import javax.crypto.Cipher -import javax.crypto.IllegalBlockSizeException -import javax.crypto.Mac -import javax.crypto.NoSuchPaddingException -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -object FullBackupExporter { - private val TAG = FullBackupExporter::class.java.simpleName - - @JvmStatic - @WorkerThread - @Throws(IOException::class) - fun export(context: Context, - attachmentSecret: AttachmentSecret, - input: SQLiteDatabase, - fileUri: Uri, - passphrase: String) { - - val baseOutputStream = context.contentResolver.openOutputStream(fileUri) - ?: throw IOException("Cannot open an output stream for the file URI: $fileUri") - - var count = 0 - try { - BackupFrameOutputStream(baseOutputStream, passphrase).use { outputStream -> - outputStream.writeDatabaseVersion(input.version) - val tables = exportSchema(input, outputStream) - for (table in tables) if (shouldExportTable(table)) { - count = when (table) { - SmsDatabase.TABLE_NAME, MmsDatabase.TABLE_NAME -> { - exportTable(table, input, outputStream, - { cursor: Cursor -> - cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0 - }, - null, - count) - } - GroupReceiptDatabase.TABLE_NAME -> { - exportTable(table, input, outputStream, - { cursor: Cursor -> - isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))) - }, - null, - count) - } - AttachmentDatabase.TABLE_NAME -> { - exportTable(table, input, outputStream, - { cursor: Cursor -> - isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))) - }, - { cursor: Cursor -> - exportAttachment(attachmentSecret, cursor, outputStream) - }, - count) - } - else -> { - exportTable(table, input, outputStream, null, null, count) - } - } - } - for (preference in BackupUtil.getBackupRecords(context)) { - EventBus.getDefault().post(BackupEvent.createProgress(++count)) - outputStream.writePreferenceEntry(preference) - } - for (preference in BackupPreferences.getBackupRecords(context)) { - EventBus.getDefault().post(BackupEvent.createProgress(++count)) - outputStream.writePreferenceEntry(preference) - } - for (avatar in AvatarHelper.getAvatarFiles(context)) { - EventBus.getDefault().post(BackupEvent.createProgress(++count)) - outputStream.writeAvatar(avatar.name, FileInputStream(avatar), avatar.length()) - } - outputStream.writeEnd() - } - EventBus.getDefault().post(BackupEvent.createFinished()) - } catch (e: Exception) { - Log.e(TAG, "Failed to make full backup.", e) - EventBus.getDefault().post(BackupEvent.createFinished(e)) - throw e - } - } - - private inline fun shouldExportTable(table: String): Boolean { - return table != PushDatabase.TABLE_NAME && - - table != LokiBackupFilesDatabase.TABLE_NAME && - table != LokiAPIDatabase.openGroupProfilePictureTable && - - table != JobDatabase.Jobs.TABLE_NAME && - table != JobDatabase.Constraints.TABLE_NAME && - table != JobDatabase.Dependencies.TABLE_NAME && - - !table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) && - !table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) && - !table.startsWith("sqlite_") - } - - @Throws(IOException::class) - private fun exportSchema(input: SQLiteDatabase, outputStream: BackupFrameOutputStream): List<String> { - val tables: MutableList<String> = LinkedList() - input.rawQuery("SELECT sql, name, type FROM sqlite_master", null).use { cursor -> - while (cursor != null && cursor.moveToNext()) { - val sql = cursor.getString(0) - val name = cursor.getString(1) - val type = cursor.getString(2) - if (sql != null) { - val isSmsFtsSecretTable = name != null && name != SearchDatabase.SMS_FTS_TABLE_NAME && name.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) - val isMmsFtsSecretTable = name != null && name != SearchDatabase.MMS_FTS_TABLE_NAME && name.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) - if (!isSmsFtsSecretTable && !isMmsFtsSecretTable) { - if ("table" == type) { - tables.add(name) - } - outputStream.writeSql(SqlStatement.newBuilder().setStatement(cursor.getString(0)).build()) - } - } - } - } - return tables - } - - @Throws(IOException::class) - private fun exportTable(table: String, - input: SQLiteDatabase, - outputStream: BackupFrameOutputStream, - predicate: Predicate<Cursor>?, - postProcess: Consumer<Cursor>?, - count: Int): Int { - var count = count - val template = "INSERT INTO $table VALUES " - input.rawQuery("SELECT * FROM $table", null).use { cursor -> - while (cursor != null && cursor.moveToNext()) { - EventBus.getDefault().post(BackupEvent.createProgress(++count)) - if (predicate != null && !predicate.test(cursor)) continue - - val statement = StringBuilder(template) - val statementBuilder = SqlStatement.newBuilder() - statement.append('(') - for (i in 0 until cursor.columnCount) { - statement.append('?') - when (cursor.getType(i)) { - Cursor.FIELD_TYPE_STRING -> { - statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder() - .setStringParamter(cursor.getString(i))) - } - Cursor.FIELD_TYPE_FLOAT -> { - statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder() - .setDoubleParameter(cursor.getDouble(i))) - } - Cursor.FIELD_TYPE_INTEGER -> { - statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder() - .setIntegerParameter(cursor.getLong(i))) - } - Cursor.FIELD_TYPE_BLOB -> { - statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder() - .setBlobParameter(ByteString.copyFrom(cursor.getBlob(i)))) - } - Cursor.FIELD_TYPE_NULL -> { - statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder() - .setNullparameter(true)) - } - else -> { - throw AssertionError("unknown type?" + cursor.getType(i)) - } - } - if (i < cursor.columnCount - 1) { - statement.append(',') - } - } - statement.append(')') - outputStream.writeSql(statementBuilder.setStatement(statement.toString()).build()) - postProcess?.accept(cursor) - } - } - return count - } - - private fun exportAttachment(attachmentSecret: AttachmentSecret, cursor: Cursor, outputStream: BackupFrameOutputStream) { - try { - val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID)) - val uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID)) - var size = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.SIZE)) - val data = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA)) - val random = cursor.getBlob(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA_RANDOM)) - if (!TextUtils.isEmpty(data) && size <= 0) { - size = calculateVeryOldStreamLength(attachmentSecret, random, data) - } - if (!TextUtils.isEmpty(data) && size > 0) { - val inputStream: InputStream = if (random != null && random.size == 32) { - ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0) - } else { - ClassicDecryptingPartInputStream.createFor(attachmentSecret, File(data)) - } - outputStream.writeAttachment(AttachmentId(rowId, uniqueId), inputStream, size) - } - } catch (e: IOException) { - Log.w(TAG, e) - } - } - - @Throws(IOException::class) - private fun calculateVeryOldStreamLength(attachmentSecret: AttachmentSecret, random: ByteArray?, data: String): Long { - var result: Long = 0 - val inputStream: InputStream = if (random != null && random.size == 32) { - ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0) - } else { - ClassicDecryptingPartInputStream.createFor(attachmentSecret, File(data)) - } - var read: Int - val buffer = ByteArray(8192) - while (inputStream.read(buffer, 0, buffer.size).also { read = it } != -1) { - result += read.toLong() - } - return result - } - - private fun isForNonExpiringMessage(db: SQLiteDatabase, mmsId: Long): Boolean { - val columns = arrayOf(MmsSmsColumns.EXPIRES_IN) - val where = MmsSmsColumns.ID + " = ?" - val args = arrayOf(mmsId.toString()) - db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null).use { mmsCursor -> - if (mmsCursor != null && mmsCursor.moveToFirst()) { - return mmsCursor.getLong(0) == 0L - } - } - return false - } - - private class BackupFrameOutputStream : Closeable, Flushable { - - private val outputStream: OutputStream - private var cipher: Cipher - private var mac: Mac - private val cipherKey: ByteArray - private val macKey: ByteArray - private val iv: ByteArray - - private var counter: Int = 0 - - constructor(outputStream: OutputStream, passphrase: String) : super() { - try { - val salt = Util.getSecretBytes(32) - val key = BackupUtil.computeBackupKey(passphrase, salt) - val derived = HKDFv3().deriveSecrets(key, "Backup Export".toByteArray(), 64) - val split = ByteUtil.split(derived, 32, 32) - cipherKey = split[0] - macKey = split[1] - cipher = Cipher.getInstance("AES/CTR/NoPadding") - mac = Mac.getInstance("HmacSHA256") - this.outputStream = outputStream - iv = Util.getSecretBytes(16) - counter = Conversions.byteArrayToInt(iv) - mac.init(SecretKeySpec(macKey, "HmacSHA256")) - val header = BackupFrame.newBuilder().setHeader(Header.newBuilder() - .setIv(ByteString.copyFrom(iv)) - .setSalt(ByteString.copyFrom(salt))) - .build().toByteArray() - outputStream.write(Conversions.intToByteArray(header.size)) - outputStream.write(header) - } catch (e: Exception) { - when (e) { - is NoSuchAlgorithmException, - is NoSuchPaddingException, - is InvalidKeyException -> { - throw AssertionError(e) - } - else -> throw e - } - } - } - - @Throws(IOException::class) - fun writeSql(statement: SqlStatement) { - write(outputStream, BackupFrame.newBuilder().setStatement(statement).build()) - } - - @Throws(IOException::class) - fun writePreferenceEntry(preference: SharedPreference?) { - write(outputStream, BackupFrame.newBuilder().setPreference(preference).build()) - } - - @Throws(IOException::class) - fun writeAvatar(avatarName: String, inputStream: InputStream, size: Long) { - write(outputStream, BackupFrame.newBuilder() - .setAvatar(Avatar.newBuilder() - .setName(avatarName) - .setLength(Util.toIntExact(size)) - .build()) - .build()) - writeStream(inputStream) - } - - @Throws(IOException::class) - fun writeAttachment(attachmentId: AttachmentId, inputStream: InputStream, size: Long) { - write(outputStream, BackupFrame.newBuilder() - .setAttachment(Attachment.newBuilder() - .setRowId(attachmentId.rowId) - .setAttachmentId(attachmentId.uniqueId) - .setLength(Util.toIntExact(size)) - .build()) - .build()) - writeStream(inputStream) - } - - @Throws(IOException::class) - fun writeSticker(rowId: Long, inputStream: InputStream, size: Long) { - write(outputStream, BackupFrame.newBuilder() - .setSticker(Sticker.newBuilder() - .setRowId(rowId) - .setLength(Util.toIntExact(size)) - .build()) - .build()) - writeStream(inputStream) - } - - @Throws(IOException::class) - fun writeDatabaseVersion(version: Int) { - write(outputStream, BackupFrame.newBuilder() - .setVersion(DatabaseVersion.newBuilder().setVersion(version)) - .build()) - } - - @Throws(IOException::class) - fun writeEnd() { - write(outputStream, BackupFrame.newBuilder().setEnd(true).build()) - } - - @Throws(IOException::class) - private fun writeStream(inputStream: InputStream) { - try { - Conversions.intToByteArray(iv, 0, counter++) - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv)) - mac.update(iv) - val buffer = ByteArray(8192) - var read: Int - while (inputStream.read(buffer).also { read = it } != -1) { - val ciphertext = cipher.update(buffer, 0, read) - if (ciphertext != null) { - outputStream.write(ciphertext) - mac.update(ciphertext) - } - } - val remainder = cipher.doFinal() - outputStream.write(remainder) - mac.update(remainder) - val attachmentDigest = mac.doFinal() - outputStream.write(attachmentDigest, 0, 10) - } catch (e: Exception) { - when (e) { - is InvalidKeyException, - is InvalidAlgorithmParameterException, - is IllegalBlockSizeException, - is BadPaddingException -> { - throw AssertionError(e) - } - else -> throw e - } - } - } - - @Throws(IOException::class) - private fun write(out: OutputStream, frame: BackupFrame) { - try { - Conversions.intToByteArray(iv, 0, counter++) - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv)) - val frameCiphertext = cipher.doFinal(frame.toByteArray()) - val frameMac = mac.doFinal(frameCiphertext) - val length = Conversions.intToByteArray(frameCiphertext.size + 10) - out.write(length) - out.write(frameCiphertext) - out.write(frameMac, 0, 10) - } catch (e: Exception) { - when (e) { - is InvalidKeyException, - is InvalidAlgorithmParameterException, - is IllegalBlockSizeException, - is BadPaddingException -> { - throw AssertionError(e) - } - else -> throw e - } - } - } - - @Throws(IOException::class) - override fun flush() { - outputStream.flush() - } - - @Throws(IOException::class) - override fun close() { - outputStream.close() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.kt deleted file mode 100644 index ba1df97d56..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.kt +++ /dev/null @@ -1,352 +0,0 @@ -package org.thoughtcrime.securesms.backup - -import android.annotation.SuppressLint -import android.content.ContentValues -import android.content.Context -import android.net.Uri -import androidx.annotation.WorkerThread -import net.sqlcipher.database.SQLiteDatabase -import org.greenrobot.eventbus.EventBus -import org.session.libsession.avatars.AvatarHelper -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.Conversions -import org.session.libsession.utilities.Util -import org.session.libsignal.crypto.kdf.HKDFv3 -import org.session.libsignal.utilities.ByteUtil -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.backup.BackupProtos.Attachment -import org.thoughtcrime.securesms.backup.BackupProtos.Avatar -import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame -import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion -import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference -import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement -import org.thoughtcrime.securesms.crypto.AttachmentSecret -import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream -import org.thoughtcrime.securesms.database.AttachmentDatabase -import org.thoughtcrime.securesms.database.GroupReceiptDatabase -import org.thoughtcrime.securesms.database.MmsDatabase -import org.thoughtcrime.securesms.database.MmsSmsColumns -import org.thoughtcrime.securesms.database.SearchDatabase -import org.thoughtcrime.securesms.database.ThreadDatabase -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.BackupUtil -import java.io.Closeable -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.security.InvalidAlgorithmParameterException -import java.security.InvalidKeyException -import java.security.MessageDigest -import java.security.NoSuchAlgorithmException -import java.util.LinkedList -import java.util.Locale -import javax.crypto.BadPaddingException -import javax.crypto.Cipher -import javax.crypto.IllegalBlockSizeException -import javax.crypto.Mac -import javax.crypto.NoSuchPaddingException -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -object FullBackupImporter { - /** - * Because BackupProtos.SharedPreference was made only to serialize string values, - * we use these 3-char prefixes to explicitly cast the values before inserting to a preference file. - */ - const val PREF_PREFIX_TYPE_INT = "i__" - const val PREF_PREFIX_TYPE_BOOLEAN = "b__" - - private val TAG = FullBackupImporter::class.java.simpleName - - @JvmStatic - @WorkerThread - @Throws(IOException::class) - fun importFromUri(context: Context, - attachmentSecret: AttachmentSecret, - db: SQLiteDatabase, - fileUri: Uri, - passphrase: String) { - - val baseInputStream = context.contentResolver.openInputStream(fileUri) - ?: throw IOException("Cannot open an input stream for the file URI: $fileUri") - - var count = 0 - try { - BackupRecordInputStream(baseInputStream, passphrase).use { inputStream -> - db.beginTransaction() - dropAllTables(db) - var frame: BackupFrame - while (!inputStream.readFrame().also { frame = it }.end) { - if (count++ % 100 == 0) EventBus.getDefault().post(BackupEvent.createProgress(count)) - when { - frame.hasVersion() -> processVersion(db, frame.version) - frame.hasStatement() -> processStatement(db, frame.statement) - frame.hasPreference() -> processPreference(context, frame.preference) - frame.hasAttachment() -> processAttachment(context, attachmentSecret, db, frame.attachment, inputStream) - frame.hasAvatar() -> processAvatar(context, frame.avatar, inputStream) - } - } - trimEntriesForExpiredMessages(context, db) - db.setTransactionSuccessful() - } - } finally { - if (db.inTransaction()) { - db.endTransaction() - } - } - EventBus.getDefault().post(BackupEvent.createFinished()) - } - - @Throws(IOException::class) - private fun processVersion(db: SQLiteDatabase, version: DatabaseVersion) { - if (version.version > db.version) { - throw DatabaseDowngradeException(db.version, version.version) - } - db.version = version.version - } - - private fun processStatement(db: SQLiteDatabase, statement: SqlStatement) { - val isForSmsFtsSecretTable = statement.statement.contains(SearchDatabase.SMS_FTS_TABLE_NAME + "_") - val isForMmsFtsSecretTable = statement.statement.contains(SearchDatabase.MMS_FTS_TABLE_NAME + "_") - val isForSqliteSecretTable = statement.statement.toLowerCase(Locale.ENGLISH).startsWith("create table sqlite_") - if (isForSmsFtsSecretTable || isForMmsFtsSecretTable || isForSqliteSecretTable) { - Log.i(TAG, "Ignoring import for statement: " + statement.statement) - return - } - val parameters: MutableList<Any?> = LinkedList() - for (parameter in statement.parametersList) { - when { - parameter.hasStringParamter() -> parameters.add(parameter.stringParamter) - parameter.hasDoubleParameter() -> parameters.add(parameter.doubleParameter) - parameter.hasIntegerParameter() -> parameters.add(parameter.integerParameter) - parameter.hasBlobParameter() -> parameters.add(parameter.blobParameter.toByteArray()) - parameter.hasNullparameter() -> parameters.add(null) - } - } - if (parameters.size > 0) { - db.execSQL(statement.statement, parameters.toTypedArray()) - } else { - db.execSQL(statement.statement) - } - } - - @Throws(IOException::class) - private fun processAttachment(context: Context, attachmentSecret: AttachmentSecret, - db: SQLiteDatabase, attachment: Attachment, - inputStream: BackupRecordInputStream) { - val partsDirectory = context.getDir(AttachmentDatabase.DIRECTORY, Context.MODE_PRIVATE) - val dataFile = File.createTempFile("part", ".mms", partsDirectory) - val output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false) - inputStream.readAttachmentTo(output.second, attachment.length) - val contentValues = ContentValues() - contentValues.put(AttachmentDatabase.DATA, dataFile.absolutePath) - contentValues.put(AttachmentDatabase.THUMBNAIL, null as String?) - contentValues.put(AttachmentDatabase.DATA_RANDOM, output.first) - db.update(AttachmentDatabase.TABLE_NAME, contentValues, - "${AttachmentDatabase.ROW_ID} = ? AND ${AttachmentDatabase.UNIQUE_ID} = ?", - arrayOf(attachment.rowId.toString(), attachment.attachmentId.toString())) - } - - @Throws(IOException::class) - private fun processAvatar(context: Context, avatar: Avatar, inputStream: BackupRecordInputStream) { - inputStream.readAttachmentTo(FileOutputStream( - AvatarHelper.getAvatarFile(context, Address.fromExternal(context, avatar.name))), avatar.length) - } - - @SuppressLint("ApplySharedPref") - private fun processPreference(context: Context, preference: SharedPreference) { - val preferences = context.getSharedPreferences(preference.file, 0) - val key = preference.key - val value = preference.value - - // See the comment next to PREF_PREFIX_TYPE_* constants. - when { - key.startsWith(PREF_PREFIX_TYPE_INT) -> - preferences.edit().putInt( - key.substring(PREF_PREFIX_TYPE_INT.length), - value.toInt() - ).commit() - key.startsWith(PREF_PREFIX_TYPE_BOOLEAN) -> - preferences.edit().putBoolean( - key.substring(PREF_PREFIX_TYPE_BOOLEAN.length), - value.toBoolean() - ).commit() - else -> - preferences.edit().putString(key, value).commit() - } - } - - private fun dropAllTables(db: SQLiteDatabase) { - db.rawQuery("SELECT name, type FROM sqlite_master", null).use { cursor -> - while (cursor != null && cursor.moveToNext()) { - val name = cursor.getString(0) - val type = cursor.getString(1) - if ("table" == type && !name.startsWith("sqlite_")) { - db.execSQL("DROP TABLE IF EXISTS $name") - } - } - } - } - - private fun trimEntriesForExpiredMessages(context: Context, db: SQLiteDatabase) { - val trimmedCondition = " NOT IN (SELECT ${MmsSmsColumns.ID} FROM ${MmsDatabase.TABLE_NAME})" - db.delete(GroupReceiptDatabase.TABLE_NAME, GroupReceiptDatabase.MMS_ID + trimmedCondition, null) - val columns = arrayOf(AttachmentDatabase.ROW_ID, AttachmentDatabase.UNIQUE_ID) - val where = AttachmentDatabase.MMS_ID + trimmedCondition - db.query(AttachmentDatabase.TABLE_NAME, columns, where, null, null, null, null).use { cursor -> - while (cursor != null && cursor.moveToNext()) { - DatabaseComponent.get(context).attachmentDatabase() - .deleteAttachment(AttachmentId(cursor.getLong(0), cursor.getLong(1))) - } - } - db.query(ThreadDatabase.TABLE_NAME, arrayOf(ThreadDatabase.ID), - ThreadDatabase.EXPIRES_IN + " > 0", null, null, null, null).use { cursor -> - while (cursor != null && cursor.moveToNext()) { - DatabaseComponent.get(context).threadDatabase().update(cursor.getLong(0), false) - } - } - } - - private class BackupRecordInputStream : Closeable { - private val inputStream: InputStream - private val cipher: Cipher - private val mac: Mac - private val cipherKey: ByteArray - private val macKey: ByteArray - private val iv: ByteArray - - private var counter = 0 - - @Throws(IOException::class) - constructor(inputStream: InputStream, passphrase: String) : super() { - try { - this.inputStream = inputStream - val headerLengthBytes = ByteArray(4) - Util.readFully(this.inputStream, headerLengthBytes) - val headerLength = Conversions.byteArrayToInt(headerLengthBytes) - val headerFrame = ByteArray(headerLength) - Util.readFully(this.inputStream, headerFrame) - val frame = BackupFrame.parseFrom(headerFrame) - if (!frame.hasHeader()) { - throw IOException("Backup stream does not start with header!") - } - val header = frame.header - iv = header.iv.toByteArray() - if (iv.size != 16) { - throw IOException("Invalid IV length!") - } - val key = BackupUtil.computeBackupKey(passphrase, if (header.hasSalt()) header.salt.toByteArray() else null) - val derived = HKDFv3().deriveSecrets(key, "Backup Export".toByteArray(), 64) - val split = ByteUtil.split(derived, 32, 32) - cipherKey = split[0] - macKey = split[1] - cipher = Cipher.getInstance("AES/CTR/NoPadding") - mac = Mac.getInstance("HmacSHA256") - mac.init(SecretKeySpec(macKey, "HmacSHA256")) - counter = Conversions.byteArrayToInt(iv) - } catch (e: Exception) { - when (e) { - is NoSuchAlgorithmException, - is NoSuchPaddingException, - is InvalidKeyException -> { - throw AssertionError(e) - } - else -> throw e - } - } - } - - @Throws(IOException::class) - fun readFrame(): BackupFrame { - return readFrame(inputStream) - } - - @Throws(IOException::class) - fun readAttachmentTo(out: OutputStream, length: Int) { - var length = length - try { - Conversions.intToByteArray(iv, 0, counter++) - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv)) - mac.update(iv) - val buffer = ByteArray(8192) - while (length > 0) { - val read = inputStream.read(buffer, 0, Math.min(buffer.size, length)) - if (read == -1) throw IOException("File ended early!") - mac.update(buffer, 0, read) - val plaintext = cipher.update(buffer, 0, read) - if (plaintext != null) { - out.write(plaintext, 0, plaintext.size) - } - length -= read - } - val plaintext = cipher.doFinal() - if (plaintext != null) { - out.write(plaintext, 0, plaintext.size) - } - out.close() - val ourMac = ByteUtil.trim(mac.doFinal(), 10) - val theirMac = ByteArray(10) - try { - Util.readFully(inputStream, theirMac) - } catch (e: IOException) { - throw IOException(e) - } - if (!MessageDigest.isEqual(ourMac, theirMac)) { - throw IOException("Bad MAC") - } - } catch (e: Exception) { - when (e) { - is InvalidKeyException, - is InvalidAlgorithmParameterException, - is IllegalBlockSizeException, - is BadPaddingException -> { - throw AssertionError(e) - } - else -> throw e - } - } - } - - @Throws(IOException::class) - private fun readFrame(`in`: InputStream?): BackupFrame { - return try { - val length = ByteArray(4) - Util.readFully(`in`, length) - val frame = ByteArray(Conversions.byteArrayToInt(length)) - Util.readFully(`in`, frame) - val theirMac = ByteArray(10) - System.arraycopy(frame, frame.size - 10, theirMac, 0, theirMac.size) - mac.update(frame, 0, frame.size - 10) - val ourMac = ByteUtil.trim(mac.doFinal(), 10) - if (!MessageDigest.isEqual(ourMac, theirMac)) { - throw IOException("Bad MAC") - } - Conversions.intToByteArray(iv, 0, counter++) - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv)) - val plaintext = cipher.doFinal(frame, 0, frame.size - 10) - BackupFrame.parseFrom(plaintext) - } catch (e: Exception) { - when (e) { - is InvalidKeyException, - is InvalidAlgorithmParameterException, - is IllegalBlockSizeException, - is BadPaddingException -> { - throw AssertionError(e) - } - else -> throw e - } - } - } - - @Throws(IOException::class) - override fun close() { - inputStream.close() - } - } - - class DatabaseDowngradeException internal constructor(currentVersion: Int, backupVersion: Int) : - IOException("Tried to import a backup with version $backupVersion into a database with version $currentVersion") -} \ No newline at end of file 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 7e732d1aa7..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 @@ -249,17 +251,12 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { viewModel.callState.collect { state -> Log.d("Loki", "Consuming view model state $state") when (state) { - CALL_RINGING -> { - if (wantsToAnswer) { - answerCall() - wantsToAnswer = false - } - } - CALL_OUTGOING -> { - } - CALL_CONNECTED -> { + CALL_RINGING -> if (wantsToAnswer) { + answerCall() wantsToAnswer = false } + CALL_CONNECTED -> wantsToAnswer = false + else -> {} } updateControls(state) } @@ -339,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 195c066d45..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java +++ /dev/null @@ -1,148 +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.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() <= System.currentTimeMillis()) { - 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/GlideBitmapListeningTarget.java b/app/src/main/java/org/thoughtcrime/securesms/components/GlideBitmapListeningTarget.java index 61094fb7df..157bc215e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/GlideBitmapListeningTarget.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/GlideBitmapListeningTarget.java @@ -4,30 +4,48 @@ import android.graphics.Bitmap; import android.graphics.drawable.Drawable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; + +import android.view.View; import android.widget.ImageView; import com.bumptech.glide.request.target.BitmapImageViewTarget; import org.session.libsignal.utilities.SettableFuture; +import java.lang.ref.WeakReference; + public class GlideBitmapListeningTarget extends BitmapImageViewTarget { private final SettableFuture<Boolean> loaded; + private final WeakReference<View> loadingView; - public GlideBitmapListeningTarget(@NonNull ImageView view, @NonNull SettableFuture<Boolean> loaded) { + public GlideBitmapListeningTarget(@NonNull ImageView view, @Nullable View loadingView, @NonNull SettableFuture<Boolean> loaded) { super(view); this.loaded = loaded; + this.loadingView = new WeakReference<View>(loadingView); } @Override protected void setResource(@Nullable Bitmap resource) { super.setResource(resource); loaded.set(true); + + View loadingViewInstance = loadingView.get(); + + if (loadingViewInstance != null) { + loadingViewInstance.setVisibility(View.GONE); + } } @Override public void onLoadFailed(@Nullable Drawable errorDrawable) { super.onLoadFailed(errorDrawable); loaded.set(true); + + View loadingViewInstance = loadingView.get(); + + if (loadingViewInstance != null) { + loadingViewInstance.setVisibility(View.GONE); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/GlideDrawableListeningTarget.java b/app/src/main/java/org/thoughtcrime/securesms/components/GlideDrawableListeningTarget.java index d177900124..406c878ec9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/GlideDrawableListeningTarget.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/GlideDrawableListeningTarget.java @@ -3,30 +3,48 @@ package org.thoughtcrime.securesms.components; import android.graphics.drawable.Drawable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; + +import android.view.View; import android.widget.ImageView; import com.bumptech.glide.request.target.DrawableImageViewTarget; import org.session.libsignal.utilities.SettableFuture; +import java.lang.ref.WeakReference; + public class GlideDrawableListeningTarget extends DrawableImageViewTarget { private final SettableFuture<Boolean> loaded; + private final WeakReference<View> loadingView; - public GlideDrawableListeningTarget(@NonNull ImageView view, @NonNull SettableFuture<Boolean> loaded) { + public GlideDrawableListeningTarget(@NonNull ImageView view, @Nullable View loadingView, @NonNull SettableFuture<Boolean> loaded) { super(view); this.loaded = loaded; + this.loadingView = new WeakReference<View>(loadingView); } @Override protected void setResource(@Nullable Drawable resource) { super.setResource(resource); loaded.set(true); + + View loadingViewInstance = loadingView.get(); + + if (loadingViewInstance != null) { + loadingViewInstance.setVisibility(View.GONE); + } } @Override public void onLoadFailed(@Nullable Drawable errorDrawable) { super.onLoadFailed(errorDrawable); loaded.set(true); + + View loadingViewInstance = loadingView.get(); + + if (loadingViewInstance != null) { + loadingViewInstance.setVisibility(View.GONE); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/LabeledSeparatorView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/LabeledSeparatorView.kt deleted file mode 100644 index df36719db2..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/LabeledSeparatorView.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.thoughtcrime.securesms.components - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.Path -import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.RelativeLayout -import network.loki.messenger.R -import network.loki.messenger.databinding.ViewSeparatorBinding -import org.thoughtcrime.securesms.util.toPx -import org.session.libsession.utilities.ThemeUtil - -class LabeledSeparatorView : RelativeLayout { - - private lateinit var binding: ViewSeparatorBinding - private val path = Path() - - private val paint: Paint by lazy { - val result = Paint() - result.style = Paint.Style.STROKE - result.color = ThemeUtil.getThemedColor(context, R.attr.dividerHorizontal) - result.strokeWidth = toPx(1, resources).toFloat() - result.isAntiAlias = true - result - } - - // region Lifecycle - constructor(context: Context) : super(context) { - setUpViewHierarchy() - } - - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { - setUpViewHierarchy() - } - - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { - setUpViewHierarchy() - } - - constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { - setUpViewHierarchy() - } - - private fun setUpViewHierarchy() { - binding = ViewSeparatorBinding.inflate(LayoutInflater.from(context)) - val layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - addView(binding.root, layoutParams) - setWillNotDraw(false) - } - // endregion - - // region Updating - override fun onDraw(c: Canvas) { - super.onDraw(c) - val w = width.toFloat() - val h = height.toFloat() - val hMargin = toPx(16, resources).toFloat() - path.reset() - path.moveTo(0.0f, h / 2) - path.lineTo(binding.titleTextView.left - hMargin, h / 2) - path.addRoundRect(binding.titleTextView.left - hMargin, toPx(1, resources).toFloat(), binding.titleTextView.right + hMargin, h - toPx(1, resources).toFloat(), h / 2, h / 2, Path.Direction.CCW) - path.moveTo(binding.titleTextView.right + hMargin, h / 2) - path.lineTo(w, h / 2) - path.close() - c.drawPath(path, paint) - } - // endregion -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java b/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java deleted file mode 100644 index 5b2199896a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java +++ /dev/null @@ -1,158 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Canvas; -import android.graphics.Color; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.util.AttributeSet; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; -import android.widget.TextView; - -import org.thoughtcrime.securesms.mms.GlideRequests; - -import org.thoughtcrime.securesms.mms.ImageSlide; -import org.thoughtcrime.securesms.mms.SlidesClickedListener; - -import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; - -import network.loki.messenger.R; -import okhttp3.HttpUrl; - -public class LinkPreviewView extends FrameLayout { - - private static final int TYPE_CONVERSATION = 0; - private static final int TYPE_COMPOSE = 1; - - private ViewGroup container; - private OutlinedThumbnailView thumbnail; - private TextView title; - private TextView site; - private View divider; - private View closeButton; - private View spinner; - - private int type; - private int defaultRadius; - private CornerMask cornerMask; - private Outliner outliner; - private CloseClickedListener closeClickedListener; - - public LinkPreviewView(Context context) { - super(context); - init(null); - } - - public LinkPreviewView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - init(attrs); - } - - private void init(@Nullable AttributeSet attrs) { - inflate(getContext(), R.layout.link_preview, this); - - container = findViewById(R.id.linkpreview_container); - thumbnail = findViewById(R.id.linkpreview_thumbnail); - title = findViewById(R.id.linkpreview_title); - site = findViewById(R.id.linkpreview_site); - divider = findViewById(R.id.linkpreview_divider); - spinner = findViewById(R.id.linkpreview_progress_wheel); - closeButton = findViewById(R.id.linkpreview_close); - defaultRadius = getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius); - cornerMask = new CornerMask(this); - outliner = new Outliner(); - - outliner.setColor(getResources().getColor(R.color.transparent)); - - if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.LinkPreviewView, 0, 0); - type = typedArray.getInt(R.styleable.LinkPreviewView_linkpreview_type, 0); - typedArray.recycle(); - } - - if (type == TYPE_COMPOSE) { - container.setBackgroundColor(Color.TRANSPARENT); - container.setPadding(0, 0, 0, 0); - divider.setVisibility(VISIBLE); - - closeButton.setOnClickListener(v -> { - if (closeClickedListener != null) { - closeClickedListener.onCloseClicked(); - } - }); - } - - setWillNotDraw(false); - } - - @Override - protected void dispatchDraw(Canvas canvas) { - super.dispatchDraw(canvas); - if (type == TYPE_COMPOSE) return; - - cornerMask.mask(canvas); - outliner.draw(canvas); - } - - public void setLoading() { - title.setVisibility(GONE); - site.setVisibility(GONE); - thumbnail.setVisibility(GONE); - spinner.setVisibility(VISIBLE); - closeButton.setVisibility(GONE); - } - - public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail, boolean showCloseButton) { - setLinkPreview(glideRequests, linkPreview, showThumbnail); - if (showCloseButton) { - closeButton.setVisibility(VISIBLE); - } else { - closeButton.setVisibility(GONE); - } - } - - public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail) { - title.setVisibility(VISIBLE); - site.setVisibility(VISIBLE); - thumbnail.setVisibility(VISIBLE); - spinner.setVisibility(GONE); - closeButton.setVisibility(VISIBLE); - - title.setText(linkPreview.getTitle()); - - HttpUrl url = HttpUrl.parse(linkPreview.getUrl()); - if (url != null) { - site.setText(url.topPrivateDomain()); - } - - if (showThumbnail && linkPreview.getThumbnail().isPresent()) { - thumbnail.setVisibility(VISIBLE); - thumbnail.setImageResource(glideRequests, new ImageSlide(getContext(), linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false); - thumbnail.showDownloadText(false); - } else { - thumbnail.setVisibility(GONE); - } - } - - public void setCorners(int topLeft, int topRight) { - cornerMask.setRadii(topLeft, topRight, 0, 0); - outliner.setRadii(topLeft, topRight, 0, 0); - thumbnail.setCorners(topLeft, defaultRadius, defaultRadius, defaultRadius); - postInvalidate(); - } - - public void setCloseClickedListener(@Nullable CloseClickedListener closeClickedListener) { - this.closeClickedListener = closeClickedListener; - } - - public void setDownloadClickedListener(SlidesClickedListener listener) { - thumbnail.setDownloadClickListener(listener); - } - - public interface CloseClickedListener { - void onCloseClicked(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java deleted file mode 100644 index 71bf8a2804..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/OutlinedThumbnailView.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.graphics.Canvas; -import android.util.AttributeSet; - -import org.session.libsession.utilities.ThemeUtil; -import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView; - -import network.loki.messenger.R; - -public class OutlinedThumbnailView extends ThumbnailView { - - private CornerMask cornerMask; - private Outliner outliner; - - public OutlinedThumbnailView(Context context) { - super(context); - init(); - } - - public OutlinedThumbnailView(Context context, AttributeSet attrs) { - super(context, attrs); - init(); - } - - private void init() { - cornerMask = new CornerMask(this); - outliner = new Outliner(); - - outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_image_outline_color)); - setWillNotDraw(false); - } - - @Override - protected void dispatchDraw(Canvas canvas) { - super.dispatchDraw(canvas); - - cornerMask.mask(canvas); - outliner.draw(canvas); - } - - public void setCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) { - cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft); - outliner.setRadii(topLeft, topRight, bottomRight, bottomLeft); - postInvalidate(); - } -} 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 0ded9f346e..52e2d52ab1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ProfilePictureView.kt @@ -2,10 +2,10 @@ package org.thoughtcrime.securesms.components import android.content.Context import android.util.AttributeSet +import android.view.LayoutInflater import android.view.View import android.widget.ImageView import android.widget.RelativeLayout -import androidx.annotation.DimenRes import com.bumptech.glide.load.engine.DiskCacheStrategy import network.loki.messenger.R import network.loki.messenger.databinding.ViewProfilePictureBinding @@ -18,48 +18,60 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests class ProfilePictureView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RelativeLayout(context, attrs) { - private val binding: ViewProfilePictureBinding by lazy { ViewProfilePictureBinding.bind(this) } - lateinit var glide: GlideRequests + private val binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this) + private val glide: GlideRequests = GlideApp.with(this) var publicKey: String? = null var displayName: String? = null var additionalPublicKey: String? = null var additionalDisplayName: String? = null var isLarge = false - private val profilePicturesCache = mutableMapOf<String, String?>() - private val unknownRecipientDrawable = ResourceContactPhoto(R.drawable.ic_profile_default) - .asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) + 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) { + update(sender) + } + // region Updating fun update(recipient: Recipient) { fun getUserDisplayName(publicKey: String): String { val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey } - fun isOpenGroupWithProfilePicture(recipient: Recipient): Boolean { - return recipient.isOpenGroupRecipient && recipient.groupAvatarId != null - } - if (recipient.isGroupRecipient && !isOpenGroupWithProfilePicture(recipient)) { + + if (recipient.isClosedGroupRecipient) { val members = DatabaseComponent.get(context).groupDatabase() .getGroupMemberAddresses(recipient.address.toGroupString(), true) .sorted() .take(2) .toMutableList() - val pk = members.getOrNull(0)?.serialize() ?: "" - publicKey = pk - displayName = getUserDisplayName(pk) - val apk = members.getOrNull(1)?.serialize() ?: "" - additionalPublicKey = apk - additionalDisplayName = getUserDisplayName(apk) + if (members.size <= 1) { + publicKey = "" + displayName = "" + additionalPublicKey = "" + additionalDisplayName = "" + } else { + val pk = members.getOrNull(0)?.serialize() ?: "" + publicKey = pk + displayName = getUserDisplayName(pk) + val apk = members.getOrNull(1)?.serialize() ?: "" + additionalPublicKey = apk + additionalDisplayName = getUserDisplayName(apk) + } } else if(recipient.isOpenGroupInboxRecipient) { - val publicKey = GroupUtil.getDecodedOpenGroupInbox(recipient.address.serialize()) + val publicKey = GroupUtil.getDecodedOpenGroupInboxSessionId(recipient.address.serialize()) this.publicKey = publicKey displayName = getUserDisplayName(publicKey) additionalPublicKey = null @@ -73,12 +85,11 @@ class ProfilePictureView @JvmOverloads constructor( } fun update() { - if (!this::glide.isInitialized) return 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) @@ -86,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) @@ -101,32 +112,43 @@ 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() - .error(unknownRecipientDrawable) + .error(glide.load(placeholder)) .diskCacheStrategy(DiskCacheStrategy.NONE) .circleCrop() .into(imageView) - } else { + } else if (recipient.isCommunityRecipient && recipient.groupAvatarId == null) { glide.clear(imageView) + glide.load(unknownOpenGroupDrawable) + .centerCrop() + .circleCrop() + .into(imageView) + } else { glide.load(placeholder) .placeholder(unknownRecipientDrawable) .centerCrop() + .circleCrop() .diskCacheStrategy(DiskCacheStrategy.NONE).circleCrop().into(imageView) } - profilePicturesCache[publicKey] = recipient.profileAvatar } else { - imageView.setImageDrawable(null) + glide.load(unknownRecipientDrawable) + .centerCrop() + .into(imageView) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SafeViewPager.kt b/app/src/main/java/org/thoughtcrime/securesms/components/SafeViewPager.kt new file mode 100644 index 0000000000..6748478736 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SafeViewPager.kt @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.components + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.viewpager.widget.ViewPager + +/** + * An extension of ViewPager to swallow erroneous multi-touch exceptions. + * + * @see https://stackoverflow.com/questions/6919292/pointerindex-out-of-range-android-multitouch + */ +class SafeViewPager @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : ViewPager(context, attrs) { + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean = try { + super.onTouchEvent(event) + } catch (e: IllegalArgumentException) { + false + } + + override fun onInterceptTouchEvent(event: MotionEvent?): Boolean = try { + super.onInterceptTouchEvent(event) + } catch (e: IllegalArgumentException) { + false + } +} 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/StickerView.java b/app/src/main/java/org/thoughtcrime/securesms/components/StickerView.java index 6214c58531..98a623eef3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/StickerView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/StickerView.java @@ -52,19 +52,4 @@ public class StickerView extends FrameLayout { public void setOnLongClickListener(@Nullable OnLongClickListener l) { image.setOnLongClickListener(l); } - - public void setSticker(@NonNull GlideRequests glideRequests, @NonNull Slide stickerSlide) { - boolean showControls = stickerSlide.asAttachment().getDataUri() == null; - - image.setImageResource(glideRequests, stickerSlide, showControls, false); - missingShade.setVisibility(showControls ? View.VISIBLE : View.GONE); - } - - public void setThumbnailClickListener(@NonNull SlideClickListener listener) { - image.setThumbnailClickListener(listener); - } - - public void setDownloadClickListener(@NonNull SlidesClickedListener listener) { - image.setDownloadClickListener(listener); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java index d512e0924c..4f0072cc24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java @@ -24,7 +24,7 @@ public class EmojiTextView extends AppCompatTextView { private static final char ELLIPSIS = '…'; private CharSequence previousText; - private BufferType previousBufferType; + private BufferType previousBufferType = BufferType.NORMAL; private float originalFontSize; private boolean useSystemEmoji; private boolean sizeChangeInProgress; @@ -49,6 +49,15 @@ public class EmojiTextView extends AppCompatTextView { } @Override public void setText(@Nullable CharSequence text, BufferType type) { + // No need to do anything special if the text is null or empty + if (text == null || text.length() == 0) { + previousText = text; + previousOverflowText = overflowText; + previousBufferType = type; + super.setText(text, type); + return; + } + EmojiParser.CandidateList candidates = EmojiProvider.getCandidates(text); if (scaleEmojis && candidates != null && candidates.allEmojis) { @@ -149,10 +158,15 @@ public class EmojiTextView extends AppCompatTextView { } private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) { - return Util.equals(previousText, text) && - Util.equals(previousOverflowText, overflowText) && - Util.equals(previousBufferType, bufferType) && - useSystemEmoji == useSystemEmoji() && + CharSequence finalPrevText = (previousText == null || previousText.length() == 0 ? "" : previousText); + CharSequence finalText = (text == null || text.length() == 0 ? "" : text); + CharSequence finalPrevOverflowText = (previousOverflowText == null || previousOverflowText.length() == 0 ? "" : previousOverflowText); + CharSequence finalOverflowText = (overflowText == null || overflowText.length() == 0 ? "" : overflowText); + + return Util.equals(finalPrevText, finalText) && + Util.equals(finalPrevOverflowText, finalOverflowText) && + Util.equals(previousBufferType, bufferType) && + useSystemEmoji == useSystemEmoji() && !sizeChangeInProgress; } 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 358a9d326b..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,12 +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( @AttrRes val iconRes: Int, - val title: CharSequence, - val action: Runnable + val title: Int, + val action: Runnable, + 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 65fb1ddbbc..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,27 +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)) } - 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/ContactSelectionListFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt index 24637c4341..0b0ddf4b3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListFragment.kt @@ -8,7 +8,6 @@ import androidx.fragment.app.Fragment import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import network.loki.messenger.databinding.ContactSelectionListFragmentBinding import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log @@ -58,7 +57,6 @@ class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks<L super.onViewCreated(view, savedInstanceState) binding.recyclerView.layoutManager = LinearLayoutManager(activity) binding.recyclerView.adapter = listAdapter - binding.swipeRefreshLayout.isEnabled = requireActivity().intent.getBooleanExtra(REFRESHABLE, true) } override fun onStop() { @@ -73,15 +71,6 @@ class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks<L fun resetQueryFilter() { setQueryFilter(null) - binding.swipeRefreshLayout.isRefreshing = false - } - - fun setRefreshing(refreshing: Boolean) { - binding.swipeRefreshLayout.isRefreshing = refreshing - } - - fun setOnRefreshListener(onRefreshListener: OnRefreshListener?) { - binding.swipeRefreshLayout.setOnRefreshListener(onRefreshListener) } override fun onCreateLoader(id: Int, args: Bundle?): Loader<List<ContactSelectionListItem>> { @@ -106,7 +95,7 @@ class ContactSelectionListFragment : Fragment(), LoaderManager.LoaderCallbacks<L return } listAdapter.items = items - binding.mainContentContainer.visibility = if (items.isEmpty()) View.GONE else View.VISIBLE + binding.recyclerView.visibility = if (items.isEmpty()) View.GONE else View.VISIBLE binding.emptyStateContainer.visibility = if (items.isEmpty()) View.VISIBLE else View.GONE } 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/contacts/SelectContactsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsActivity.kt index 8f2675159b..538cd35077 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectContactsActivity.kt @@ -3,12 +3,12 @@ package org.thoughtcrime.securesms.contacts import android.app.Activity import android.content.Intent import android.os.Bundle -import androidx.loader.app.LoaderManager -import androidx.loader.content.Loader -import androidx.recyclerview.widget.LinearLayoutManager import android.view.Menu import android.view.MenuItem import android.view.View +import androidx.loader.app.LoaderManager +import androidx.loader.content.Loader +import androidx.recyclerview.widget.LinearLayoutManager import network.loki.messenger.R import network.loki.messenger.databinding.ActivitySelectContactsBinding import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity @@ -49,7 +49,7 @@ class SelectContactsActivity : PassphraseRequiredActionBarActivity(), LoaderMana LoaderManager.getInstance(this).initLoader(0, null, this) } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { + override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_done, menu) return members.isNotEmpty() } @@ -70,7 +70,7 @@ class SelectContactsActivity : PassphraseRequiredActionBarActivity(), LoaderMana private fun update(members: List<String>) { this.members = members - binding.mainContentContainer.visibility = if (members.isEmpty()) View.GONE else View.VISIBLE + binding.recyclerView.visibility = if (members.isEmpty()) View.GONE else View.VISIBLE binding.emptyStateContainer.visibility = if (members.isEmpty()) View.VISIBLE else View.GONE invalidateOptionsMenu() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt index e88cf1d08b..36a8c1adf5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/UserView.kt @@ -7,6 +7,7 @@ import android.view.View import android.widget.LinearLayout import network.loki.messenger.R import network.loki.messenger.databinding.ViewUserBinding +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.conversation.v2.utilities.MentionManagerUtilities @@ -47,15 +48,14 @@ class UserView : LinearLayout { // region Updating fun bind(user: Recipient, glide: GlideRequests, actionIndicator: ActionIndicator, isSelected: Boolean = false) { + val isLocalUser = user.isLocalNumber fun getUserDisplayName(publicKey: String): String { + if (isLocalUser) return context.getString(R.string.MessageRecord_you) val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey) return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey } - val threadID = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(user) - MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(threadID, context) // FIXME: This is a bad place to do this val address = user.address.serialize() - binding.profilePictureView.root.glide = glide - binding.profilePictureView.root.update(user) + binding.profilePictureView.update(user) binding.actionIndicatorImageView.setImageResource(R.drawable.ic_baseline_edit_24) binding.nameTextView.text = if (user.isGroupRecipient) user.name else getUserDisplayName(address) when (actionIndicator) { @@ -87,7 +87,7 @@ class UserView : LinearLayout { } fun unbind() { - binding.profilePictureView.root.recycle() + binding.profilePictureView.recycle() } // endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/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/paging/ConversationPager.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/paging/ConversationPager.kt new file mode 100644 index 0000000000..827c394546 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/paging/ConversationPager.kt @@ -0,0 +1,129 @@ +package org.thoughtcrime.securesms.conversation.paging + +import androidx.annotation.WorkerThread +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.recyclerview.widget.DiffUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.session.libsession.messaging.contacts.Contact +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.SessionContactDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord + +private const val TIME_BUCKET = 600000L // bucket into 10 minute increments + +private fun config() = PagingConfig( + pageSize = 25, + maxSize = 100, + enablePlaceholders = false +) + +fun Long.bucketed(): Long = (TIME_BUCKET - this % TIME_BUCKET) + this + +fun conversationPager(threadId: Long, initialKey: PageLoad? = null, db: MmsSmsDatabase, contactDb: SessionContactDatabase) = Pager(config(), initialKey = initialKey) { + ConversationPagingSource(threadId, db, contactDb) +} + +class ConversationPagerDiffCallback: DiffUtil.ItemCallback<MessageAndContact>() { + override fun areItemsTheSame(oldItem: MessageAndContact, newItem: MessageAndContact): Boolean = + oldItem.message.id == newItem.message.id && oldItem.message.isMms == newItem.message.isMms + + override fun areContentsTheSame(oldItem: MessageAndContact, newItem: MessageAndContact): Boolean = + oldItem == newItem +} + +data class MessageAndContact(val message: MessageRecord, + val contact: Contact?) + +data class PageLoad(val fromTime: Long, val toTime: Long? = null) + +class ConversationPagingSource( + private val threadId: Long, + private val messageDb: MmsSmsDatabase, + private val contactDb: SessionContactDatabase + ): PagingSource<PageLoad, MessageAndContact>() { + + override fun getRefreshKey(state: PagingState<PageLoad, MessageAndContact>): PageLoad? { + val anchorPosition = state.anchorPosition ?: return null + val anchorPage = state.closestPageToPosition(anchorPosition) ?: return null + val next = anchorPage.nextKey?.fromTime + val previous = anchorPage.prevKey?.fromTime ?: anchorPage.data.firstOrNull()?.message?.dateSent ?: return null + return PageLoad(previous, next) + } + + private val contactCache = mutableMapOf<String, Contact>() + + @WorkerThread + private fun getContact(sessionId: String): Contact? { + contactCache[sessionId]?.let { contact -> + return contact + } ?: run { + contactDb.getContactWithSessionID(sessionId)?.let { contact -> + contactCache[sessionId] = contact + return contact + } + } + return null + } + + override suspend fun load(params: LoadParams<PageLoad>): LoadResult<PageLoad, MessageAndContact> { + val pageLoad = params.key ?: withContext(Dispatchers.IO) { + messageDb.getConversationSnippet(threadId).use { + val reader = messageDb.readerFor(it) + var record: MessageRecord? = null + if (reader != null) { + record = reader.next + while (record != null && record.isDeleted) { + record = reader.next + } + } + record?.dateSent?.let { fromTime -> + PageLoad(fromTime) + } + } + } ?: return LoadResult.Page(emptyList(), null, null) + + val result = withContext(Dispatchers.IO) { + val cursor = messageDb.getConversationPage( + threadId, + pageLoad.fromTime, + pageLoad.toTime ?: -1L, + params.loadSize + ) + val processedList = mutableListOf<MessageAndContact>() + val reader = messageDb.readerFor(cursor) + while (reader.next != null && !invalid) { + reader.current?.let { item -> + val contact = getContact(item.individualRecipient.address.serialize()) + processedList += MessageAndContact(item, contact) + } + } + reader.close() + processedList.toMutableList() + } + + val hasNext = withContext(Dispatchers.IO) { + if (result.isEmpty()) return@withContext false + val lastTime = result.last().message.dateSent + messageDb.hasNextPage(threadId, lastTime) + } + + val nextCheckTime = if (hasNext) { + val lastSent = result.last().message.dateSent + if (lastSent == pageLoad.fromTime) null else lastSent + } else null + + val hasPrevious = withContext(Dispatchers.IO) { messageDb.hasPreviousPage(threadId, pageLoad.fromTime) } + val nextKey = if (!hasNext) null else nextCheckTime + val prevKey = if (!hasPrevious) null else messageDb.getPreviousPage(threadId, pageLoad.fromTime, params.loadSize) + + return LoadResult.Page( + data = result, // next check time is not null if drop is true + prevKey = prevKey?.let { PageLoad(it, pageLoad.fromTime) }, + nextKey = nextKey?.let { PageLoad(it) } + ) + } +} 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 99e7c90615..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 @@ -32,15 +32,31 @@ class ContactListAdapter( class ContactViewHolder(private val binding: ViewContactBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(contact: ContactListItem.Contact, glide: GlideRequests, listener: (Recipient) -> Unit) { - binding.profilePictureView.root.glide = glide - binding.profilePictureView.root.update(contact.recipient) + binding.profilePictureView.update(contact.recipient) binding.nameTextView.text = contact.displayName binding.root.setOnClickListener { listener(contact.recipient) } + + // 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.root.recycle() - } + fun unbind() { binding.profilePictureView.recycle() } } class HeaderViewHolder( @@ -53,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 { @@ -73,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/start/NewConversationHomeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt index 2e62932ab0..92f050f76a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationHomeFragment.kt @@ -55,7 +55,7 @@ class NewConversationHomeFragment : Fragment() { val displayName = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionId ContactListItem.Contact(it, displayName) }.sortedBy { it.displayName } - .groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.first().uppercase() } + .groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.firstOrNull()?.uppercase() ?: unknownSectionTitle } .toMutableMap() contactGroups.remove(unknownSectionTitle)?.let { contactGroups.put(unknownSectionTitle, it) } adapter.items = contactGroups.flatMap { entry -> listOf(ContactListItem.Header(entry.key)) + entry.value } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index a819c3fa22..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 @@ -6,7 +6,6 @@ import android.animation.ValueAnimator import android.content.ClipData import android.content.ClipboardManager import android.content.Context -import android.content.DialogInterface import android.content.Intent import android.content.res.Resources import android.database.Cursor @@ -19,7 +18,10 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.provider.MediaStore +import android.text.SpannableStringBuilder +import android.text.SpannedString import android.text.TextUtils +import android.text.style.StyleSpan import android.util.Pair import android.util.TypedValue import android.view.ActionMode @@ -28,16 +30,21 @@ import android.view.MenuItem import android.view.MotionEvent import android.view.View import android.view.WindowManager -import android.widget.LinearLayout import android.widget.RelativeLayout import android.widget.Toast +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import androidx.annotation.DimenRes -import androidx.appcompat.app.AlertDialog +import androidx.core.text.set +import androidx.core.text.toSpannable import androidx.core.view.drawToBitmap +import androidx.core.view.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.loader.app.LoaderManager import androidx.loader.content.Loader @@ -46,28 +53,36 @@ import androidx.recyclerview.widget.RecyclerView import com.annimon.stream.Stream import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.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 +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 import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel import org.session.libsession.messaging.utilities.SessionId +import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.GroupUtil @@ -84,14 +99,19 @@ import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional import org.session.libsignal.utilities.hexEncodedPrivateKey import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.ExpirationDialog import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.audio.AudioRecorder +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.dialogs.BlockedDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.SendSeedDialog @@ -107,20 +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.BaseDialog -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 @@ -148,15 +164,22 @@ 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.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 import java.util.concurrent.ExecutionException +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject @@ -165,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. @@ -172,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 { @@ -184,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 @@ -216,23 +239,19 @@ 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 } val recipient = Recipient.from(this, address, false) - threadId = threadDb.getOrCreateThreadIdFor(recipient) + threadId = storage.getOrCreateThreadIdFor(recipient.address) } } ?: finish() } 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()) @@ -250,11 +269,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val searchViewModel: SearchViewModel by viewModels() var searchViewItem: MenuItem? = null + private val bufferedLastSeenChannel = Channel<Long>(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private var emojiPickerVisible = false + private val isScrolledToBottom: Boolean - get() { - val position = layoutManager?.findFirstCompletelyVisibleItemPosition() ?: 0 - return position == 0 - } + 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? } @@ -264,17 +286,25 @@ 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) } + // There is a bug when initially joining a community where all messages will immediately be marked + // as read if we reverse the message list so this is now hard-coded to false + private val reverseMessageList = false + private val adapter by lazy { - val cursor = mmsSmsDb.getConversation(viewModel.threadId, !isIncomingMessageRequestThread()) + val cursor = mmsSmsDb.getConversation(viewModel.threadId, reverseMessageList) val adapter = ConversationAdapter( this, cursor, + storage.getLastSeen(viewModel.threadId), + reverseMessageList, onItemPress = { message, position, view, event -> handlePress(message, position, view, event) }, @@ -282,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 { @@ -295,10 +325,20 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe onDeselect(message, position, it) } }, + onAttachmentNeedsDownload = { attachmentId, mmsId -> + lifecycleScope.launch(Dispatchers.IO) { + JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId)) + } + }, glide = glide, 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 } @@ -310,10 +350,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_camera_24, hasOpaqueBackground = true) } private val messageToScrollTimestamp = AtomicLong(-1) private val messageToScrollAuthor = AtomicReference<Address?>(null) + private val firstLoad = AtomicBoolean(true) 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 @@ -328,7 +374,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe const val PICK_GIF = 10 const val PICK_FROM_LIBRARY = 12 const val INVITE_CONTACTS = 124 - } // endregion @@ -337,44 +382,86 @@ 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 thread = threadDb.getRecipientForThreadId(viewModel.threadId) - if (thread == null) { + val recipient = viewModel.recipient + val openGroup = recipient.let { viewModel.openGroup } + if (recipient == null || (recipient.isCommunityRecipient && openGroup == null)) { Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show() return finish() } - setUpRecyclerView() + setUpToolBar() setUpInputBar() setUpLinkPreviewObserver() restoreDraftIfNeeded() setUpUiStateObserver() + binding!!.scrollToBottomButton.setOnClickListener { - val layoutManager = binding?.conversationRecyclerView?.layoutManager ?: return@setOnClickListener + val layoutManager = (binding?.conversationRecyclerView?.layoutManager as? LinearLayoutManager) ?: return@setOnClickListener + val targetPosition = if (reverseMessageList) 0 else adapter.itemCount + if (layoutManager.isSmoothScrolling) { - binding?.conversationRecyclerView?.scrollToPosition(0) + binding?.conversationRecyclerView?.scrollToPosition(targetPosition) } else { - binding?.conversationRecyclerView?.smoothScrollToPosition(0) + // It looks like 'smoothScrollToPosition' will actually load all intermediate items in + // order to do the scroll, this can be very slow if there are a lot of messages so + // instead we check the current position and if there are more than 10 items to scroll + // we jump instantly to the 10th item and scroll from there (this should happen quick + // enough to give a similar scroll effect without having to load everything) +// val position = if (reverseMessageList) layoutManager.findFirstVisibleItemPosition() else layoutManager.findLastVisibleItemPosition() +// val targetBuffer = if (reverseMessageList) 10 else Math.max(0, (adapter.itemCount - 1) - 10) +// if (position > targetBuffer) { +// binding?.conversationRecyclerView?.scrollToPosition(targetBuffer) +// } + + binding?.conversationRecyclerView?.post { + binding?.conversationRecyclerView?.smoothScrollToPosition(targetPosition) + } } } - unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId) + updateUnreadCountIndicator() - setUpTypingObserver() - setUpRecipientObserver() - updateSubtitle() - getLatestOpenGroupInfoIfNeeded() + updatePlaceholder() setUpBlockedBanner() binding!!.searchBottomBar.setEventListener(this) - setUpSearchResultObserver() - scrollToFirstUnreadMessageIfNeeded() - showOrHideInputIfNeeded() + updateSendAfterApprovalText() setUpMessageRequestsBar() - viewModel.recipient?.let { recipient -> - if (recipient.isOpenGroupRecipient && viewModel.openGroup == null) { - Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show() - return finish() + + // 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) { + // Note: We are accessing the `adapter` property because we want it to be loaded on + // the background thread to avoid blocking the UI thread and potentially hanging when + // transitioning to the activity + weakActivity.get()?.adapter ?: return@launch + + // 'Get' instead of 'GetAndSet' here because we want to trigger the highlight in 'onFirstLoad' + // by triggering 'jumpToMessage' using these values + val messageTimestamp = messageToScrollTimestamp.get() + val author = messageToScrollAuthor.get() + val targetPosition = if (author != null && messageTimestamp >= 0) mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, messageTimestamp, author, reverseMessageList) else -1 + + withContext(Dispatchers.Main) { + setUpRecyclerView() + setUpTypingObserver() + setUpRecipientObserver() + getLatestOpenGroupInfoIfNeeded() + setUpSearchResultObserver() + scrollToFirstUnreadMessageIfNeeded() + setUpOutdatedClientBanner() + + if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { + binding?.conversationRecyclerView?.scrollToPosition(targetPosition) + } + else { + scrollToFirstUnreadMessageIfNeeded(true) + } } } @@ -382,18 +469,37 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ViewUtil.findStubById(this, R.id.conversation_reaction_scrubber_stub) reactionDelegate = ConversationReactionDelegate(reactionOverlayStub) reactionDelegate.setOnReactionSelectedListener(this) + lifecycleScope.launch { + // only update the conversation every 3 seconds maximum + // channel is rendezvous and shouldn't block on try send calls as often as we want + 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) + } + } + } + } } override fun onResume() { super.onResume() ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId) - val recipient = viewModel.recipient ?: return - threadDb.markAllAsRead(viewModel.threadId, recipient.isOpenGroupRecipient) + contentResolver.registerContentObserver( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, screenshotObserver ) + viewModel.run { + binding?.toolbarContent?.update(recipient ?: return, openGroup, expirationConfiguration) + } } override fun onPause() { @@ -410,26 +516,47 @@ 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(baseDialog: BaseDialog, tag: String?) { - baseDialog.show(supportFragmentManager, tag) + override fun showDialog(dialogFragment: DialogFragment, tag: String?) { + dialogFragment.show(supportFragmentManager, tag) } override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<Cursor> { - return ConversationLoader(viewModel.threadId, !isIncomingMessageRequestThread(), this@ConversationActivityV2) + return ConversationLoader(viewModel.threadId, reverseMessageList, this@ConversationActivityV2) } override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) { + val oldCount = adapter.itemCount + val newCount = cursor?.count ?: 0 adapter.changeCursor(cursor) + if (cursor != null) { val messageTimestamp = messageToScrollTimestamp.getAndSet(-1) val author = messageToScrollAuthor.getAndSet(null) - if (author != null && messageTimestamp >= 0) { - jumpToMessage(author, messageTimestamp, null) + val initialUnreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId) + + // Update the unreadCount value to be loaded from the database since we got a new message + if (firstLoad.get() || oldCount != newCount || initialUnreadCount != unreadCount) { + // Update the unreadCount value to be loaded from the database since we got a new + // message (we need to store it in a local variable as it can get overwritten on + // another thread before the 'firstLoad.getAndSet(false)' case below) + unreadCount = initialUnreadCount + updateUnreadCountIndicator() } + + if (author != null && messageTimestamp >= 0) { + jumpToMessage(author, messageTimestamp, firstLoad.get(), null) + } else { + if (firstLoad.getAndSet(false)) scrollToFirstUnreadMessageIfNeeded(true) + handleRecyclerViewScrolled() + } + } + updatePlaceholder() + viewModel.recipient?.let { + maybeUpdateToolbar(recipient = it) + setUpOutdatedClientBanner() } } @@ -440,66 +567,97 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // called from onCreate private fun setUpRecyclerView() { binding!!.conversationRecyclerView.adapter = adapter - val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, !isIncomingMessageRequestThread()) + val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, reverseMessageList) binding!!.conversationRecyclerView.layoutManager = layoutManager // Workaround for the fact that CursorRecyclerViewAdapter doesn't auto-update automatically (even though it says it will) LoaderManager.getInstance(this).restartLoader(0, null, this) 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 + } }) } + 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 private fun setUpToolBar() { - setSupportActionBar(binding?.toolbar) + val binding = binding ?: return + setSupportActionBar(binding.toolbar) val actionBar = supportActionBar ?: return val recipient = viewModel.recipient ?: return 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.root.layoutParams = LinearLayout.LayoutParams(size, size) - binding!!.toolbarContent.profilePictureView.root.glide = glide - MentionManagerUtilities.populateUserPublicKeyCacheIfNeeded(viewModel.threadId, this) - val profilePictureView = binding!!.toolbarContent.profilePictureView.root - viewModel.recipient?.let { recipient -> - profilePictureView.update(recipient) - } + binding!!.toolbarContent.bind( + this, + viewModel.threadId, + recipient, + viewModel.expirationConfiguration, + viewModel.openGroup + ) + maybeUpdateToolbar(recipient) } // called from onCreate private fun setUpInputBar() { - 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 @@ -566,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 @@ -613,43 +784,65 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (uiState.isMessageRequestAccepted == true) { binding?.messageRequestBar?.visibility = View.GONE } + if (!uiState.conversationExists && !isFinishing) { + // Conversation should be deleted now, just go back + finish() + } } } } - private fun scrollToFirstUnreadMessageIfNeeded() { + private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false, shouldHighlight: Boolean = false): Int { val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(viewModel.threadId).first() - val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return - if (lastSeenItemPosition <= 3) { return } + val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return -1 + + // If this is triggered when first opening a conversation then we want to position the top + // of the first unread message in the middle of the screen + if (isFirstLoad && !reverseMessageList) { + layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2)) + if (shouldHighlight) { highlightViewAtPosition(lastSeenItemPosition) } + return lastSeenItemPosition + } + + if (lastSeenItemPosition <= 3) { return lastSeenItemPosition } + binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) + return lastSeenItemPosition + } + + private fun highlightViewAtPosition(position: Int) { + binding?.conversationRecyclerView?.post { + (layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight() + } } override fun onPrepareOptionsMenu(menu: Menu): Boolean { val recipient = viewModel.recipient ?: return false - if (!isMessageRequestThread()) { + if (!viewModel.isMessageRequestThread) { ConversationMenuHelper.onPrepareOptionsMenu( menu, menuInflater, recipient, - viewModel.threadId, this - ) { onOptionsItemSelected(it) } + ) } - super.onPrepareOptionsMenu(menu) + maybeUpdateToolbar(recipient) return true } override fun onDestroy() { viewModel.saveDraft(binding?.inputBar?.text?.trim() ?: "") + cancelVoiceMessage() tearDownRecipientObserver() super.onDestroy() binding = null -// actionBarBinding = null } // endregion // region Animation & Updating override fun onModified(recipient: Recipient) { + viewModel.updateRecipient() + runOnUiThread { val threadRecipient = viewModel.recipient ?: return@runOnUiThread if (threadRecipient.isContactRecipient) { @@ -657,25 +850,25 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } setUpMessageRequestsBar() invalidateOptionsMenu() - updateSubtitle() + updateSendAfterApprovalText() showOrHideInputIfNeeded() - binding?.toolbarContent?.profilePictureView?.root?.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() { @@ -698,36 +891,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun acceptMessageRequest() { binding?.messageRequestBar?.isVisible = false - binding?.conversationRecyclerView?.layoutManager = - LinearLayoutManager(this, LinearLayoutManager.VERTICAL, true) - adapter.notifyDataSetChanged() viewModel.acceptMessageRequest() - LoaderManager.getInstance(this).restartLoader(0, null, this) + lifecycleScope.launch(Dispatchers.IO) { ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@ConversationActivityV2) } } - 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 @@ -786,15 +965,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val recipient = viewModel.recipient ?: return if (!isShowingMentionCandidatesView) { additionalContentContainer.removeAllViews() - val view = MentionCandidatesView(this) + val view = MentionCandidatesView(this).apply { + contentDescription = context.getString(R.string.AccessibilityId_mentions_list) + } 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 @@ -840,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 @@ -898,20 +1079,69 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun handleRecyclerViewScrolled() { - // FIXME: Checking isScrolledToBottom is a quick fix for an issue where the - // typing indicator overlays the recycler view when scrolled up val binding = binding ?: return + + // 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 - binding.typingIndicatorViewContainer.isVisible - showOrHidScrollToBottomButton() - val firstVisiblePosition = layoutManager?.findFirstVisibleItemPosition() ?: -1 - unreadCount = min(unreadCount, firstVisiblePosition).coerceAtLeast(0) + + showScrollToBottomButtonIfApplicable() + val maybeTargetVisiblePosition = if (reverseMessageList) layoutManager?.findFirstVisibleItemPosition() else layoutManager?.findLastVisibleItemPosition() + val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION + if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) { + 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 { + val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() } + ?: RecyclerView.NO_POSITION + unreadCount = min(unreadCount, layoutUnreadCount).coerceAtLeast(0) + } updateUnreadCountIndicator() } - private fun showOrHidScrollToBottomButton(show: Boolean = true) { - binding?.scrollToBottomButton?.isVisible = show && !isScrolledToBottom && adapter.itemCount > 0 + 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 + binding.placeholderText.isVisible = showPlaceholder + if (showPlaceholder) { + if (insertParam != null) { + val span = getText(textResource) as SpannedString + val annotations = span.getSpans(0, span.length, StyleSpan::class.java) + val boldSpan = annotations.first() + val spannedParam = insertParam.toSpannable() + spannedParam[0 until spannedParam.length] = StyleSpan(boldSpan.style) + val originalStart = span.getSpanStart(boldSpan) + val originalEnd = span.getSpanEnd(boldSpan) + val newString = SpannableStringBuilder(span) + .replace(originalStart, originalEnd, spannedParam) + binding.placeholderText.text = newString + } else { + binding.placeholderText.setText(textResource) + } + } + } + + private fun showScrollToBottomButtonIfApplicable() { + binding?.scrollToBottomButton?.isVisible = !emojiPickerVisible && !isScrolledToBottom && adapter.itemCount > 0 } private fun updateUnreadCountIndicator() { @@ -924,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 @@ -961,19 +1171,18 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun block(deleteThread: Boolean) { - val title = R.string.RecipientPreferenceActivity_block_this_contact_question - val message = R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact - AlertDialog.Builder(this) - .setTitle(title) - .setMessage(message) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.RecipientPreferenceActivity_block) { _, _ -> + showSessionDialog { + title(R.string.RecipientPreferenceActivity_block_this_contact_question) + text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) + destructiveButton(R.string.RecipientPreferenceActivity_block, R.string.AccessibilityId_block_confirm) { viewModel.block() if (deleteThread) { viewModel.deleteThread() finish() } - }.show() + } + cancelButton() + } } override fun copySessionID(sessionId: String) { @@ -983,33 +1192,37 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() } - override fun showExpiringMessagesDialog(thread: Recipient) { + override fun copyOpenGroupUrl(thread: Recipient) { + if (!thread.isCommunityRecipient) { return } + + val threadId = threadDb.getThreadIdIfExistsFor(thread) ?: return + val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return + + val clip = ClipData.newPlainText("Community URL", openGroup.joinURL) + val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager + manager.setPrimaryClip(clip) + Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + } + + override fun showDisappearingMessages(thread: Recipient) { if (thread.isClosedGroupRecipient) { - val group = groupDb.getGroup(thread.address.toGroupString()).orNull() - if (group?.isActive == false) { return } - } - ExpirationDialog.show(this, thread.expireMessages) { expirationTime: Int -> - recipientDb.setExpireMessages(thread, expirationTime) - val message = ExpirationTimerUpdate(expirationTime) - message.recipient = thread.address.serialize() - message.sentTimestamp = System.currentTimeMillis() - val expiringMessageManager = ApplicationContext.getInstance(this).expiringMessageManager - 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() { - val title = R.string.ConversationActivity_unblock_this_contact_question - val message = R.string.ConversationActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact - AlertDialog.Builder(this) - .setTitle(title) - .setMessage(message) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.ConversationActivity_unblock) { _, _ -> - viewModel.unblock() - }.show() + showSessionDialog { + title(R.string.ConversationActivity_unblock_this_contact_question) + text(R.string.ConversationActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact) + destructiveButton( + R.string.ConversationActivity_unblock, + R.string.AccessibilityId_block_confirm + ) { viewModel.unblock() } + cancelButton() + } } // `position` is the adapter position; not the visual position @@ -1039,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) } @@ -1069,33 +1283,37 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Log.e("Loki", "Failed to show emoji picker", e) return } + + val binding = binding ?: return + + emojiPickerVisible = true ViewUtil.hideKeyboard(this, visibleMessageView) - binding?.reactionsShade?.isVisible = true - showOrHidScrollToBottomButton(false) - binding?.conversationRecyclerView?.suppressLayout(true) + binding.reactionsShade.isVisible = true + binding.scrollToBottomButton.isVisible = false + binding.conversationRecyclerView.suppressLayout(true) reactionDelegate.setOnActionSelectedListener(ReactionsToolbarListener(message)) reactionDelegate.setOnHideListener(object: ConversationReactionOverlay.OnHideListener { override fun startHide() { - binding?.reactionsShade?.let { + emojiPickerVisible = false + binding.reactionsShade.let { ViewUtil.fadeOut(it, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE) } - showOrHidScrollToBottomButton(true) + showScrollToBottomButtonIfApplicable() } override fun onHide() { - binding?.conversationRecyclerView?.suppressLayout(false) + binding.conversationRecyclerView.suppressLayout(false) WindowUtil.setLightStatusBarFromTheme(this@ConversationActivityV2); WindowUtil.setLightNavigationBarFromTheme(this@ConversationActivityV2); } }) - val contentBounds = Rect() - visibleMessageView.messageContentView.getGlobalVisibleRect(contentBounds) + val topLeft = intArrayOf(0, 0).also { visibleMessageView.messageContentView.getLocationInWindow(it) } val selectedConversationModel = SelectedConversationModel( messageContentBitmap, - contentBounds.left.toFloat(), - contentBounds.top.toFloat(), + topLeft[0].toFloat(), + topLeft[1].toFloat(), visibleMessageView.messageContentView.width, message.isOutgoing, visibleMessageView.messageContentView @@ -1114,6 +1332,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe sendEmojiRemoval(emoji, messageRecord) } else { sendEmojiReaction(emoji, messageRecord) + RecentEmojiPageModel.onCodePointSelected(emoji) // Save to recently used reaction emojis } } @@ -1121,7 +1340,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // Create the message val recipient = viewModel.recipient ?: return val reactionMessage = VisibleMessage() - val emojiTimestamp = System.currentTimeMillis() + val emojiTimestamp = SnodeAPI.nowWithOffset reactionMessage.sentTimestamp = emojiTimestamp val author = textSecurePreferences.getLocalNumber()!! // Put the message in the database @@ -1140,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) @@ -1154,7 +1373,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun sendEmojiRemoval(emoji: String, originalMessage: MessageRecord) { val recipient = viewModel.recipient ?: return val message = VisibleMessage() - val emojiTimestamp = System.currentTimeMillis() + val emojiTimestamp = SnodeAPI.nowWithOffset message.sentTimestamp = emojiTimestamp val author = textSecurePreferences.getLocalNumber()!! reactionDb.deleteReaction(emoji, MessageId(originalMessage.id, originalMessage.isMms), author, false) @@ -1164,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) @@ -1338,22 +1557,27 @@ 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() { val recipient = viewModel.recipient ?: return if (recipient.isContactRecipient && recipient.isBlocked) { - BlockedDialog(recipient).show(supportFragmentManager, "Blocked Dialog") + BlockedDialog(recipient, this).show(supportFragmentManager, "Blocked Dialog") return } val binding = binding ?: return - if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) { + val sentMessageInfo = if (binding.inputBar.linkPreview != null || binding.inputBar.quote != null) { sendAttachments(listOf(), getMessageBody(), binding.inputBar.quote, binding.inputBar.linkPreview) } else { sendTextOnlyMessage() } + + // Jump to the newly sent message once it gets added + if (sentMessageInfo != null) { + messageToScrollAuthor.set(sentMessageInfo.first) + messageToScrollTimestamp.set(sentMessageInfo.second) + } } override fun commitInputContent(contentUri: Uri) { @@ -1371,21 +1595,27 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false) { - val recipient = viewModel.recipient ?: return + private fun sendTextOnlyMessage(hasPermissionToSendSeed: Boolean = false): Pair<Address, Long>? { + val recipient = viewModel.recipient ?: return null + val sentTimestamp = SnodeAPI.nowWithOffset processMessageRequestApproval() val text = getMessageBody() val userPublicKey = textSecurePreferences.getLocalNumber() val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) { val dialog = SendSeedDialog { sendTextOnlyMessage(true) } - return dialog.show(supportFragmentManager, "Send Seed Dialog") + dialog.show(supportFragmentManager, "Send Seed Dialog") + return null } // Create the message - val message = VisibleMessage() - message.sentTimestamp = System.currentTimeMillis() + 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() @@ -1400,14 +1630,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe MessageSender.send(message, recipient.address) // Send a typing stopped message ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) + return Pair(recipient.address, sentTimestamp) } - private fun sendAttachments(attachments: List<Attachment>, body: String?, quotedMessage: MessageRecord? = null, linkPreview: LinkPreview? = null) { - val recipient = viewModel.recipient ?: return + 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() - message.sentTimestamp = System.currentTimeMillis() + val message = VisibleMessage().applyExpiryMode(viewModel.threadId) + message.sentTimestamp = sentTimestamp message.text = body val quote = quotedMessage?.let { val quotedAttachments = (it as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: listOf() @@ -1422,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() @@ -1441,28 +1682,28 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe MessageSender.send(message, recipient.address, attachments, quote, linkPreview) // Send a typing stopped message ApplicationContext.getInstance(this).typingStatusSender.onTypingStopped(viewModel.threadId) + return Pair(recipient.address, sentTimestamp) } private fun showGIFPicker() { val hasSeenGIFMetaDataWarning: Boolean = textSecurePreferences.hasSeenGIFMetaDataWarning() if (!hasSeenGIFMetaDataWarning) { - val builder = AlertDialog.Builder(this) - builder.setTitle("Search GIFs?") - builder.setMessage("You will not have full metadata protection when sending GIFs.") - builder.setPositiveButton("OK") { dialog: DialogInterface, _: Int -> - textSecurePreferences.setHasSeenGIFMetaDataWarning() - AttachmentManager.selectGif(this, PICK_GIF) - dialog.dismiss() + showSessionDialog { + title(R.string.giphy_permission_title) + text(R.string.giphy_permission_message) + button(R.string.continue_2) { + textSecurePreferences.setHasSeenGIFMetaDataWarning() + selectGif() + } + cancelButton() } - builder.setNegativeButton( - "Cancel" - ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } - builder.create().show() } else { - AttachmentManager.selectGif(this, PICK_GIF) + selectGif() } } + private fun selectGif() = AttachmentManager.selectGif(this, PICK_GIF) + private fun showDocumentPicker() { AttachmentManager.selectDocument(this, PICK_DOCUMENT) } @@ -1487,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> { @@ -1537,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)!! @@ -1562,7 +1804,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe showVoiceMessageUI() window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) audioRecorder.startRecording() - stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 60000) // Limit voice messages to 1 minute each + stopAudioHandler.postDelayed(stopVoiceMessageRecordingTask, 300000) // Limit voice messages to 5 minute each } else { Permissions.with(this) .request(Manifest.permission.RECORD_AUDIO) @@ -1603,41 +1845,71 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe handleLongPress(messages.first(), 0) //TODO: begin selection mode } + // 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 ?: return + 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) != null } - if (recipient.isOpenGroupRecipient) { - val messageCount = 1 - val builder = AlertDialog.Builder(this) - builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) - builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) - builder.setCancelable(true) - builder.setPositiveButton(R.string.delete) { _, _ -> - for (message in messages) { - viewModel.deleteForEveryone(message) + 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() } - endActionMode() + cancelButton { endActionMode() } } - builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> - dialog.dismiss() - endActionMode() - } - builder.show() + // 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 bottomSheet.onDeleteForMeTapped = { - for (message in messages) { - viewModel.deleteLocally(message) - } + messages.forEach(viewModel::deleteLocally) bottomSheet.dismiss() endActionMode() } bottomSheet.onDeleteForEveryoneTapped = { - for (message in messages) { - viewModel.deleteForEveryone(message) - } + messages.forEach(viewModel::deleteForEveryone) bottomSheet.dismiss() endActionMode() } @@ -1646,56 +1918,37 @@ 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 - val builder = AlertDialog.Builder(this) - builder.setTitle(resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageCount, messageCount)) - builder.setMessage(resources.getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messageCount, messageCount)) - builder.setCancelable(true) - builder.setPositiveButton(R.string.delete) { _, _ -> - for (message in messages) { - viewModel.deleteLocally(message) + 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() } - endActionMode() + cancelButton(::endActionMode) } - builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> - dialog.dismiss() - endActionMode() - } - builder.show() } } override fun banUser(messages: Set<MessageRecord>) { - val builder = AlertDialog.Builder(this) - builder.setTitle(R.string.ConversationFragment_ban_selected_user) - builder.setMessage("This will ban the selected user from this room. It won't ban them from other rooms.") - builder.setCancelable(true) - builder.setPositiveButton(R.string.ban) { _, _ -> - viewModel.banUser(messages.first().individualRecipient) - endActionMode() + showSessionDialog { + title(R.string.ConversationFragment_ban_selected_user) + text("This will ban the selected user from this room. It won't ban them from other rooms.") + button(R.string.ban) { viewModel.banUser(messages.first().individualRecipient); endActionMode() } + cancelButton(::endActionMode) } - builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> - dialog.dismiss() - endActionMode() - } - builder.show() } override fun banAndDeleteAll(messages: Set<MessageRecord>) { - val builder = AlertDialog.Builder(this) - builder.setTitle(R.string.ConversationFragment_ban_selected_user) - builder.setMessage("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.") - builder.setCancelable(true) - builder.setPositiveButton(R.string.ban) { _, _ -> - viewModel.banAndDeleteAll(messages.first().individualRecipient) - endActionMode() + showSessionDialog { + title(R.string.ConversationFragment_ban_selected_user) + text("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.") + button(R.string.ban) { viewModel.banAndDeleteAll(messages.first()); endActionMode() } + cancelButton(::endActionMode) } - builder.setNegativeButton(android.R.string.cancel) { dialog, _ -> - dialog.dismiss() - endActionMode() - } - builder.show() } override fun copyMessages(messages: Set<MessageRecord>) { @@ -1736,6 +1989,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe endActionMode() } + override fun resyncMessage(messages: Set<MessageRecord>) { + messages.iterator().forEach { messageRecord -> + ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey, isResync = true) + } + endActionMode() + } + override fun resendMessage(messages: Set<MessageRecord>) { messages.iterator().forEach { messageRecord -> ResendMessageUtilities.resend(this, messageRecord, viewModel.blindedPublicKey) @@ -1743,16 +2003,37 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe endActionMode() } + private val handleMessageDetail = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + val message = result.data?.extras?.getLong(MESSAGE_TIMESTAMP) + ?.let(mmsSmsDb::getMessageForTimestamp) + + val set = setOfNotNull(message) + + when (result.resultCode) { + ON_REPLY -> reply(set) + ON_RESEND -> resendMessage(set) + ON_DELETE -> deleteMessages(set) + } + } + override fun showMessageDetail(messages: Set<MessageRecord>) { - val intent = Intent(this, MessageDetailActivity::class.java) - intent.putExtra(MessageDetailActivity.MESSAGE_TIMESTAMP, messages.first().timestamp) - push(intent) + Intent(this, MessageDetailActivity::class.java) + .apply { putExtra(MESSAGE_TIMESTAMP, messages.first().timestamp) } + .let { handleMessageDetail.launch(it) } + endActionMode() } override fun saveAttachment(messages: Set<MessageRecord>) { val message = messages.first() as MmsMessageRecord - SaveAttachmentTask.showWarningDialog(this, { _, _ -> + + // 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) .maxSdkVersion(Build.VERSION_CODES.P) @@ -1780,15 +2061,19 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe Toast.LENGTH_LONG).show() } .execute() - }) + } } override fun reply(messages: Set<MessageRecord>) { val recipient = viewModel.recipient ?: return - binding?.inputBar?.draftQuote(recipient, messages.first(), glide) + messages.firstOrNull()?.let { binding?.inputBar?.draftQuote(recipient, it, glide) } endActionMode() } + override fun destroyActionMode() { + this.actionMode = null + } + private fun sendScreenshotNotification() { val recipient = viewModel.recipient ?: return if (recipient.isGroupRecipient) return @@ -1800,7 +2085,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private fun sendMediaSavedNotification() { val recipient = viewModel.recipient ?: return if (recipient.isGroupRecipient) { return } - val timestamp = System.currentTimeMillis() + val timestamp = SnodeAPI.nowWithOffset val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) MessageSender.send(message, recipient.address) @@ -1834,7 +2119,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (result == null) return@Observer if (result.getResults().isNotEmpty()) { result.getResults()[result.position]?.let { - jumpToMessage(it.messageRecipient.address, it.receivedTimestampMs) { + jumpToMessage(it.messageRecipient.address, it.sentTimestampMs, true) { searchViewModel.onMissingResult() } } } @@ -1871,15 +2156,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe this.searchViewModel.onMoveDown() } - private fun jumpToMessage(author: Address, timestamp: Long, onMessageNotFound: Runnable?) { + private fun jumpToMessage(author: Address, timestamp: Long, highlight: Boolean, onMessageNotFound: Runnable?) { SimpleTask.run(lifecycle, { - mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author) - }) { p: Int -> moveToMessagePosition(p, onMessageNotFound) } + mmsSmsDb.getMessagePositionInConversation(viewModel.threadId, timestamp, author, reverseMessageList) + }) { p: Int -> moveToMessagePosition(p, highlight, onMessageNotFound) } } - private fun moveToMessagePosition(position: Int, onMessageNotFound: Runnable?) { + private fun moveToMessagePosition(position: Int, highlight: Boolean, onMessageNotFound: Runnable?) { if (position >= 0) { binding?.conversationRecyclerView?.scrollToPosition(position) + + if (highlight) { + runOnUiThread { + highlightViewAtPosition(position) + } + } } else { onMessageNotFound?.run() } @@ -1892,6 +2183,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val selectedItems = setOf(message) when (action) { ConversationReactionOverlay.Action.REPLY -> reply(selectedItems) + ConversationReactionOverlay.Action.RESYNC -> resyncMessage(selectedItems) ConversationReactionOverlay.Action.RESEND -> resendMessage(selectedItems) ConversationReactionOverlay.Action.DOWNLOAD -> saveAttachment(selectedItems) ConversationReactionOverlay.Action.COPY_MESSAGE -> copyMessages(selectedItems) @@ -1900,7 +2192,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe ConversationReactionOverlay.Action.DELETE -> deleteMessages(selectedItems) ConversationReactionOverlay.Action.BAN_AND_DELETE_ALL -> banAndDeleteAll(selectedItems) ConversationReactionOverlay.Action.BAN_USER -> banUser(selectedItems) - ConversationReactionOverlay.Action.COPY_SESSION_ID -> TODO() + ConversationReactionOverlay.Action.COPY_SESSION_ID -> copySessionID(selectedItems) + } + } + } + + // 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) } } } 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 17a47a843f..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 @@ -1,14 +1,11 @@ package org.thoughtcrime.securesms.conversation.v2 -import android.app.AlertDialog import android.content.Context 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 @@ -21,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 @@ -31,18 +27,23 @@ import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.preferences.PrivacySettingsActivity +import org.thoughtcrime.securesms.showSessionDialog +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.min class ConversationAdapter( context: Context, cursor: Cursor, + originalLastSeen: Long, + private val isReversed: Boolean, private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit, private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit, private val onDeselect: (MessageRecord, Int) -> Unit, + private val onAttachmentNeedsDownload: (Long, Long) -> Unit, private val glide: GlideRequests, lifecycleCoroutineScope: LifecycleCoroutineScope -) - : CursorRecyclerViewAdapter<ViewHolder>(context, cursor) { +) : CursorRecyclerViewAdapter<ViewHolder>(context, cursor) { private val messageDB by lazy { DatabaseComponent.get(context).mmsSmsDatabase() } private val contactDB by lazy { DatabaseComponent.get(context).sessionContactDatabase() } var selectedItems = mutableSetOf<MessageRecord>() @@ -52,6 +53,9 @@ class ConversationAdapter( private val updateQueue = Channel<String>(1024, onBufferOverflow = BufferOverflow.DROP_OLDEST) 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) { while (isActive) { @@ -81,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 { @@ -94,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.") } @@ -106,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 @@ -120,7 +124,20 @@ class ConversationAdapter( } val contact = contactCache[senderIdHash] - visibleMessageView.bind(message, messageBefore, getMessageAfter(position, cursor), glide, searchQuery, contact, senderId, visibleMessageViewDelegate) + visibleMessageView.bind( + message, + messageBefore, + getMessageAfter(position, cursor), + glide, + searchQuery, + contact, + senderId, + lastSeen.get(), + visibleMessageViewDelegate, + onAttachmentNeedsDownload, + lastSentMessageId + ) + if (!message.isDeleted) { visibleMessageView.onPress = { event -> onItemPress(message, viewHolder.adapterPosition, visibleMessageView, event) } visibleMessageView.onSwipeToReply = { onItemSwipeToReply(message, viewHolder.adapterPosition) } @@ -135,17 +152,15 @@ class ConversationAdapter( viewHolder.view.bind(message, messageBefore) if (message.isCallLog && message.isFirstMissedCall) { viewHolder.view.setOnClickListener { - AlertDialog.Builder(context) - .setTitle(R.string.CallNotificationBuilder_first_call_title) - .setMessage(R.string.CallNotificationBuilder_first_call_message) - .setPositiveButton(R.string.activity_settings_title) { _, _ -> - val intent = Intent(context, PrivacySettingsActivity::class.java) - context.startActivity(intent) + context.showSessionDialog { + title(R.string.CallNotificationBuilder_first_call_title) + text(R.string.CallNotificationBuilder_first_call_message) + button(R.string.activity_settings_title) { + Intent(context, PrivacySettingsActivity::class.java) + .let(context::startActivity) } - .setNeutralButton(R.string.cancel) { d, _ -> - d.dismiss() - } - .show() + cancelButton() + } } } else { viewHolder.view.setOnClickListener(null) @@ -161,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) @@ -174,19 +189,24 @@ class ConversationAdapter( private fun getMessageBefore(position: Int, cursor: Cursor): MessageRecord? { // The message that's visually before the current one is actually after the current // one for the cursor because the layout is reversed - if (!cursor.moveToPosition(position + 1)) { return null } + if (isReversed && !cursor.moveToPosition(position + 1)) { return null } + if (!isReversed && !cursor.moveToPosition(position - 1)) { return null } + return messageDB.readerFor(cursor).current } private fun getMessageAfter(position: Int, cursor: Cursor): MessageRecord? { // The message that's visually after the current one is actually before the current // one for the cursor because the layout is reversed - if (!cursor.moveToPosition(position - 1)) { return null } + if (isReversed && !cursor.moveToPosition(position - 1)) { return null } + if (!isReversed && !cursor.moveToPosition(position + 1)) { return null } + return messageDB.readerFor(cursor).current } override fun changeCursor(cursor: Cursor?) { super.changeCursor(cursor) + val toRemove = mutableSetOf<MessageRecord>() val toDeselect = mutableSetOf<Pair<Int, MessageRecord>>() for (selected in selectedItems) { @@ -208,11 +228,30 @@ class ConversationAdapter( fun findLastSeenItemPosition(lastSeenTimestamp: Long): Int? { val cursor = this.cursor - if (lastSeenTimestamp <= 0L || cursor == null || !isActiveCursor) return null + if (cursor == null || !isActiveCursor) return null + if (lastSeenTimestamp == 0L) { + if (isReversed && cursor.moveToLast()) { return cursor.position } + if (!isReversed && cursor.moveToFirst()) { return cursor.position } + } + + // Loop from the newest message to the oldest until we find one older (or equal to) + // the lastSeenTimestamp, then return that message index for (i in 0 until itemCount) { - cursor.moveToPosition(i) - val message = messageDB.readerFor(cursor).current - if (message.isOutgoing || message.dateReceived <= lastSeenTimestamp) { return i } + if (isReversed) { + cursor.moveToPosition(i) + val (outgoing, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor) + if (outgoing || dateSent <= lastSeenTimestamp) { + return i + } + } + else { + val index = ((itemCount - 1) - i) + cursor.moveToPosition(index) + val (outgoing, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor) + if (outgoing || dateSent <= lastSeenTimestamp) { + return min(itemCount - 1, (index + 1)) + } + } } return null } @@ -222,8 +261,8 @@ class ConversationAdapter( if (timestamp <= 0L || cursor == null || !isActiveCursor) return null for (i in 0 until itemCount) { cursor.moveToPosition(i) - val message = messageDB.readerFor(cursor).current - if (message.dateSent == timestamp) { return i } + val (_, dateSent) = messageDB.timestampAndDirectionForCurrent(cursor) + if (dateSent == timestamp) { return i } } return null } @@ -232,4 +271,11 @@ class ConversationAdapter( this.searchQuery = query notifyDataSetChanged() } + + fun getTimestampForItemAt(firstVisiblePosition: Int): Long? { + val cursor = this.cursor ?: return null + if (!cursor.moveToPosition(firstVisiblePosition)) return null + val message = messageDB.readerFor(cursor).current ?: return null + return message.timestamp + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/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 995dcda2f2..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.java +++ /dev/null @@ -1,888 +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 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); - 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(); - - View conversationBubble = conversationItem.findViewById(R.id.conversation_item_bubble); - conversationBubble.setLayoutParams(new LinearLayout.LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight())); - conversationBubble.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot)); - TextView conversationTimestamp = conversationItem.findViewById(R.id.conversation_item_timestamp); - 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) { - View bubble = conversationItem.findViewById(R.id.conversation_item_bubble); - View timestamp = conversationItem.findViewById(R.id.conversation_item_timestamp); - conversationItem.removeAllViewsInLayout(); - conversationItem.addView(message.isOutgoing() ? timestamp : bubble); - conversationItem.addView(message.isOutgoing() ? bubble : timestamp); - conversationItem.requestLayout(); - } - - private void showAfterLayout(@NonNull MessageRecord messageRecord, - @NonNull PointF lastSeenDownPoint, - boolean isMessageOnLeft) { - contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord)); - - float itemX = isMessageOnLeft ? scrubberHorizontalMargin : - selectedConversationModel.getBubbleX() - conversationItem.getWidth() + selectedConversationModel.getBubbleWidth(); - conversationItem.setX(itemX); - conversationItem.setY(selectedConversationModel.getBubbleY() - statusBarHeight); - - Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap(); - boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth(); - - int overlayHeight = getHeight(); - int bubbleWidth = selectedConversationModel.getBubbleWidth(); - - float endX = itemX; - float endY = conversationItem.getY(); - 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(); - - float contextMenuTop = endY + conversationItemSnapshot.getHeight(); - reactionBarBackgroundY = getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); - } - - 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); - - conversationItem.animate() - .x(endX) - .y(endY) - .scaleX(endScale) - .scaleY(endScale) - .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))); - // Reply - if (!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))); - } - // 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))); - } - // 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 - if (message.isFailed()) { - items.add(new ActionItem(R.attr.menu_info_icon, getContext().getResources().getString(R.string.conversation_context__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO))); - } - // 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))); - } - // 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))); - } - - 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, - 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 4d78653abc..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,25 +1,39 @@ package org.thoughtcrime.securesms.conversation.v2 +import android.content.Context + import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope + 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.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( @@ -29,14 +43,35 @@ class ConversationViewModel( private val storage: Storage ) : ViewModel() { - private val _uiState = MutableStateFlow(ConversationUiState()) + val showSendAfterApprovalText: Boolean + get() = recipient?.run { isContactRecipient && !isLocalNumber && !hasApprovedMe() } ?: false + + private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true)) val uiState: StateFlow<ConversationUiState> = _uiState - val recipient: Recipient? - get() = repository.maybeGetRecipientForThreadId(threadId) + 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) + } val openGroup: OpenGroup? - get() = storage.getOpenGroup(threadId) + get() = _openGroup.value val serverCapabilities: List<String> get() = openGroup?.let { storage.getServerCapabilities(it.server) } ?: listOf() @@ -47,12 +82,49 @@ 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) { + 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) { - repository.saveDraft(threadId, text) + GlobalScope.launch(Dispatchers.IO) { + repository.saveDraft(threadId, text) + } } fun getDraft(): String? { - return repository.getDraft(threadId) + val draft: String? = repository.getDraft(threadId) + + viewModelScope.launch(Dispatchers.IO) { + repository.clearDrafts(threadId) + } + + return draft } fun inviteContacts(contacts: List<Recipient>) { @@ -78,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") } } @@ -112,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") @@ -160,6 +254,17 @@ class ConversationViewModel( return repository.hasReceived(threadId) } + fun updateRecipient() { + _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?): Factory @@ -182,7 +287,23 @@ class ConversationViewModel( data class UiMessage(val id: Long, val message: String) data class ConversationUiState( - val isOxenHostedOpenGroup: Boolean = false, val uiMessages: List<UiMessage> = emptyList(), - val isMessageRequestAccepted: Boolean? = null + val isMessageRequestAccepted: Boolean? = null, + val conversationExists: Boolean ) + +data class RetrieveOnce<T>(val retrieval: () -> T?) { + private var triedToRetrieve: Boolean = false + private var _value: T? = null + + val value: T? + get() { + if (triedToRetrieve) { return _value } + + triedToRetrieve = true + _value = retrieval() + return _value + } + + fun updateTo(value: T?) { _value = value } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt index 66f33cf299..b6212b8542 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DeleteOptionsBottomSheet.kt @@ -69,7 +69,6 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen override fun onStart() { super.onStart() val window = dialog?.window ?: return - val isLightMode = UiModeUtilities.isDayUiMode(requireContext()) - window.setDimAmount(if (isLightMode) 0.1f else 0.75f) + window.setDimAmount(0.6f) } } \ No newline at end of file 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 27f75701d9..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 @@ -1,88 +1,403 @@ package org.thoughtcrime.securesms.conversation.v2 +import android.annotation.SuppressLint +import android.content.Intent import android.os.Bundle -import android.view.View +import android.view.LayoutInflater +import android.view.MotionEvent.ACTION_UP +import androidx.activity.viewModels +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import network.loki.messenger.R -import network.loki.messenger.databinding.ActivityMessageDetailBinding -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.messaging.utilities.SessionId -import org.session.libsession.messaging.utilities.SodiumUtilities -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.ExpirationUtil -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.IdPrefix +import network.loki.messenger.databinding.ViewVisibleMessageContentBinding +import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity -import org.thoughtcrime.securesms.conversation.v2.utilities.ResendMessageUtilities import org.thoughtcrime.securesms.database.Storage -import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.util.DateUtils -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +import org.thoughtcrime.securesms.ui.AppTheme +import org.thoughtcrime.securesms.ui.Avatar +import org.thoughtcrime.securesms.ui.CarouselNextButton +import org.thoughtcrime.securesms.ui.CarouselPrevButton +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.CellNoMargin +import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.HorizontalPagerIndicator +import org.thoughtcrime.securesms.ui.ItemButton +import org.thoughtcrime.securesms.ui.PreviewTheme +import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider +import org.thoughtcrime.securesms.ui.TitledText +import org.thoughtcrime.securesms.ui.blackAlpha40 +import org.thoughtcrime.securesms.ui.colorDestructive +import org.thoughtcrime.securesms.ui.destructiveButtonColors import javax.inject.Inject @AndroidEntryPoint -class MessageDetailActivity: PassphraseRequiredActionBarActivity() { - private lateinit var binding: ActivityMessageDetailBinding - var messageRecord: MessageRecord? = null +class MessageDetailActivity : PassphraseRequiredActionBarActivity() { @Inject lateinit var storage: Storage - // region Settings + private val viewModel: MessageDetailsViewModel by viewModels() + companion object { // Extras const val MESSAGE_TIMESTAMP = "message_timestamp" + + const val ON_REPLY = 1 + const val ON_RESEND = 2 + const val ON_DELETE = 3 } - // endregion override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { super.onCreate(savedInstanceState, ready) - binding = ActivityMessageDetailBinding.inflate(layoutInflater) - setContentView(binding.root) + title = resources.getString(R.string.conversation_context__menu_message_details) - val timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L) - // We only show this screen for messages fail to send, - // so the author of the messages must be the current user. - val author = Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!) - messageRecord = DatabaseComponent.get(this).mmsSmsDatabase().getMessageFor(timestamp, author) - val threadId = messageRecord!!.threadId - val openGroup = storage.getOpenGroup(threadId) - val blindedKey = openGroup?.let { group -> - val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return@let null - val blindingEnabled = storage.getServerCapabilities(group.server).contains(OpenGroupApi.Capability.BLIND.name.lowercase()) - if (blindingEnabled) { - SodiumUtilities.blindedKeyPair(group.publicKey, userEdKeyPair)?.publicKey?.asBytes - ?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString - } else null - } - updateContent() - binding.resendButton.setOnClickListener { - ResendMessageUtilities.resend(this, messageRecord!!, blindedKey) - finish() + + viewModel.timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L) + + ComposeView(this) + .apply { setContent { MessageDetailsScreen() } } + .let(::setContentView) + + lifecycleScope.launch { + viewModel.eventFlow.collect { + when (it) { + Event.Finish -> finish() + is Event.StartMediaPreview -> startActivity( + getPreviewIntent(this@MessageDetailActivity, it.args) + ) + } + } } } - fun updateContent() { - val dateLocale = Locale.getDefault() - val dateFormatter: SimpleDateFormat = DateUtils.getDetailedDateFormatter(this, dateLocale) - binding.sentTime.text = dateFormatter.format(Date(messageRecord!!.dateSent)) - - val errorMessage = DatabaseComponent.get(this).lokiMessageDatabase().getErrorMessage(messageRecord!!.getId()) ?: "Message failed to send." - binding.errorMessage.text = errorMessage - - if (messageRecord!!.expiresIn <= 0 || messageRecord!!.expireStarted <= 0) { - binding.expiresContainer.visibility = View.GONE - } else { - binding.expiresContainer.visibility = View.VISIBLE - val elapsed = System.currentTimeMillis() - messageRecord!!.expireStarted - val remaining = messageRecord!!.expiresIn - elapsed - - val duration = ExpirationUtil.getExpirationDisplayValue(this, Math.max((remaining / 1000).toInt(), 1)) - binding.expiresIn.text = duration + @Composable + private fun MessageDetailsScreen() { + val state by viewModel.stateFlow.collectAsState() + AppTheme { + MessageDetails( + state = state, + onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null, + onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } }, + onDelete = { setResultAndFinish(ON_DELETE) }, + onClickImage = { viewModel.onClickImage(it) }, + onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload, + ) } } -} \ No newline at end of file + + private fun setResultAndFinish(code: Int) { + Bundle().apply { putLong(MESSAGE_TIMESTAMP, viewModel.timestamp) } + .let(Intent()::putExtras) + .let { setResult(code, it) } + + finish() + } +} + +@SuppressLint("ClickableViewAccessibility") +@Composable +fun MessageDetails( + state: MessageDetailsState, + onReply: (() -> Unit)? = null, + onResend: (() -> Unit)? = null, + onDelete: () -> Unit = {}, + onClickImage: (Int) -> Unit = {}, + onAttachmentNeedsDownload: (Long, Long) -> Unit = { _, _ -> } +) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + state.record?.let { message -> + AndroidView( + modifier = Modifier.padding(horizontal = 32.dp), + factory = { + ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(it)).mainContainerConstraint.apply { + bind( + message, + thread = state.thread!!, + onAttachmentNeedsDownload = onAttachmentNeedsDownload, + suppressThumbnails = true + ) + + setOnTouchListener { _, event -> + if (event.actionMasked == ACTION_UP) onContentClick(event) + true + } + } + } + ) + } + Carousel(state.imageAttachments) { onClickImage(it) } + state.nonImageAttachmentFileDetails?.let { FileDetails(it) } + CellMetadata(state) + CellButtons( + onReply, + onResend, + onDelete, + ) + } +} + +@Composable +fun CellMetadata( + state: MessageDetailsState, +) { + state.apply { + if (listOfNotNull(sent, received, error, senderInfo).isEmpty()) return + CellWithPaddingAndMargin { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + TitledText(sent) + TitledText(received) + TitledErrorText(error) + senderInfo?.let { + TitledView(state.fromTitle) { + Row { + sender?.let { Avatar(it) } + TitledMonospaceText(it) + } + } + } + } + } + } +} + +@Composable +fun CellButtons( + onReply: (() -> Unit)? = null, + onResend: (() -> Unit)? = null, + onDelete: () -> Unit = {}, +) { + Cell { + Column { + onReply?.let { + ItemButton( + stringResource(R.string.reply), + R.drawable.ic_message_details__reply, + onClick = it + ) + Divider() + } + onResend?.let { + ItemButton( + stringResource(R.string.resend), + R.drawable.ic_message_details__refresh, + onClick = it + ) + Divider() + } + ItemButton( + stringResource(R.string.delete), + R.drawable.ic_message_details__trash, + colors = destructiveButtonColors(), + onClick = onDelete + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Carousel(attachments: List<Attachment>, onClick: (Int) -> Unit) { + if (attachments.isEmpty()) return + + val pagerState = rememberPagerState { attachments.size } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row { + CarouselPrevButton(pagerState) + Box(modifier = Modifier.weight(1f)) { + CellCarousel(pagerState, attachments, onClick) + HorizontalPagerIndicator(pagerState) + ExpandButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + ) { onClick(pagerState.currentPage) } + } + CarouselNextButton(pagerState) + } + attachments.getOrNull(pagerState.currentPage)?.fileDetails?.let { FileDetails(it) } + } +} + +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalGlideComposeApi::class +) +@Composable +private fun CellCarousel( + pagerState: PagerState, + attachments: List<Attachment>, + onClick: (Int) -> Unit +) { + CellNoMargin { + HorizontalPager(state = pagerState) { i -> + GlideImage( + contentScale = ContentScale.Crop, + modifier = Modifier + .aspectRatio(1f) + .clickable { onClick(i) }, + model = attachments[i].uri, + contentDescription = attachments[i].fileName ?: stringResource(id = R.string.image) + ) + } + } +} + +@Composable +fun ExpandButton(modifier: Modifier = Modifier, onClick: () -> Unit) { + Surface( + shape = CircleShape, + color = blackAlpha40, + modifier = modifier, + contentColor = Color.White, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_expand), + contentDescription = stringResource(id = R.string.expand), + modifier = Modifier.clickable { onClick() }, + ) + } +} + + +@Preview +@Composable +fun PreviewMessageDetails( + @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int +) { + PreviewTheme(themeResId) { + MessageDetails( + state = MessageDetailsState( + nonImageAttachmentFileDetails = listOf( + TitledText(R.string.message_details_header__file_id, "Screen Shot 2023-07-06 at 11.35.50 am.png"), + TitledText(R.string.message_details_header__file_type, "image/png"), + TitledText(R.string.message_details_header__file_size, "195.6kB"), + TitledText(R.string.message_details_header__resolution, "342x312"), + ), + sent = TitledText(R.string.message_details_header__sent, "6:12 AM Tue, 09/08/2022"), + received = TitledText(R.string.message_details_header__received, "6:12 AM Tue, 09/08/2022"), + error = TitledText(R.string.message_details_header__error, "Message failed to send"), + senderInfo = TitledText("Connor", "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54"), + ) + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun FileDetails(fileDetails: List<TitledText>) { + if (fileDetails.isEmpty()) return + + CellWithPaddingAndMargin(padding = 0.dp) { + FlowRow( + modifier = Modifier.padding(vertical = 24.dp, horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + fileDetails.forEach { + BoxWithConstraints { + TitledText( + it, + modifier = Modifier + .widthIn(min = maxWidth.div(2)) + .padding(horizontal = 12.dp) + .width(IntrinsicSize.Max) + ) + } + } + } + } +} + +@Composable +fun TitledErrorText(titledText: TitledText?) { + TitledText( + titledText, + valueStyle = LocalTextStyle.current.copy(color = colorDestructive) + ) +} + +@Composable +fun TitledMonospaceText(titledText: TitledText?) { + TitledText( + titledText, + valueStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace) + ) +} + +@Composable +fun TitledText( + titledText: TitledText?, + modifier: Modifier = Modifier, + valueStyle: TextStyle = LocalTextStyle.current, +) { + titledText?.apply { + TitledView(title, modifier) { + Text(text, style = valueStyle, modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +fun TitledView(title: GetString, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Title(title) + content() + } +} + +@Composable +fun Title(title: GetString) { + Text(title.string(), fontWeight = FontWeight.Bold) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt new file mode 100644 index 0000000000..ba153a6b36 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt @@ -0,0 +1,174 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.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 +import org.session.libsession.messaging.jobs.AttachmentDownloadJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress +import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsession.utilities.Util +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.MediaPreviewArgs +import org.thoughtcrime.securesms.database.AttachmentDatabase +import org.thoughtcrime.securesms.database.LokiMessageDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.mms.ImageSlide +import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.repository.ConversationRepository +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.TitledText +import java.util.Date +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@HiltViewModel +class MessageDetailsViewModel @Inject constructor( + private val attachmentDb: AttachmentDatabase, + private val lokiMessageDatabase: LokiMessageDatabase, + private val mmsSmsDatabase: MmsSmsDatabase, + private val threadDb: ThreadDatabase, + private val repository: ConversationRepository, +) : ViewModel() { + + private var job: Job? = null + + private val state = MutableStateFlow(MessageDetailsState()) + val stateFlow = state.asStateFlow() + + private val event = Channel<Event>() + val eventFlow = event.receiveAsFlow() + + var timestamp: Long = 0L + set(value) { + job?.cancel() + + field = value + val record = mmsSmsDatabase.getMessageForTimestamp(timestamp) + + if (record == null) { + viewModelScope.launch { event.send(Event.Finish) } + return + } + + 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() + + MessageDetailsState( + attachments = slides.map(::Attachment), + record = record, + sent = dateSent.let(::Date).toString().let { TitledText(R.string.message_details_header__sent, it) }, + received = dateReceived.let(::Date).toString().let { TitledText(R.string.message_details_header__received, it) }, + error = lokiMessageDatabase.getErrorMessage(id)?.let { TitledText(R.string.message_details_header__error, it) }, + senderInfo = individualRecipient.run { name?.let { TitledText(it, address.serialize()) } }, + sender = individualRecipient, + thread = threadDb.getRecipientForThreadId(threadId)!!, + ) + } + } + + private val Slide.details: List<TitledText> + get() = listOfNotNull( + fileName.orNull()?.let { TitledText(R.string.message_details_header__file_id, it) }, + TitledText(R.string.message_details_header__file_type, asAttachment().contentType), + TitledText(R.string.message_details_header__file_size, Util.getPrettyFileSize(fileSize)), + takeIf { it is ImageSlide } + ?.let(Slide::asAttachment) + ?.run { "${width}x$height" } + ?.let { TitledText(R.string.message_details_header__resolution, it) }, + attachmentDb.duration(this)?.let { TitledText(R.string.message_details_header__duration, it) }, + ) + + private fun AttachmentDatabase.duration(slide: Slide): String? = + slide.takeIf { it.hasAudio() } + ?.run { asAttachment() as? DatabaseAttachment } + ?.run { getAttachmentAudioExtras(attachmentId)?.durationMs } + ?.takeIf { it > 0 } + ?.let { + String.format( + "%01d:%02d", + TimeUnit.MILLISECONDS.toMinutes(it), + TimeUnit.MILLISECONDS.toSeconds(it) % 60 + ) + } + + fun Attachment(slide: Slide): Attachment = + Attachment(slide.details, slide.fileName.orNull(), slide.uri, slide is ImageSlide) + + fun onClickImage(index: Int) { + val state = state.value + val mmsRecord = state.mmsRecord ?: return + val slide = mmsRecord.slideDeck.slides[index] ?: return + // only open to downloaded images + if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) { + // Restart download here (on IO thread) + (slide.asAttachment() as? DatabaseAttachment)?.let { attachment -> + onAttachmentNeedsDownload(attachment.attachmentId.rowId, state.mmsRecord.getId()) + } + } + + if (slide.isInProgress) return + + viewModelScope.launch { + MediaPreviewArgs(slide, state.mmsRecord, state.thread) + .let(Event::StartMediaPreview) + .let { event.send(it) } + } + } + + fun onAttachmentNeedsDownload(attachmentId: Long, mmsId: Long) { + viewModelScope.launch(Dispatchers.IO) { + JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mmsId)) + } + } +} + +data class MessageDetailsState( + val attachments: List<Attachment> = emptyList(), + val imageAttachments: List<Attachment> = attachments.filter { it.hasImage }, + val nonImageAttachmentFileDetails: List<TitledText>? = attachments.firstOrNull { !it.hasImage }?.fileDetails, + val record: MessageRecord? = null, + val mmsRecord: MmsMessageRecord? = record as? MmsMessageRecord, + val sent: TitledText? = null, + val received: TitledText? = null, + val error: TitledText? = null, + val senderInfo: TitledText? = null, + val sender: Recipient? = null, + val thread: Recipient? = null, +) { + val fromTitle = GetString(R.string.message_details_header__from) + val canReply = record?.isOpenGroupInvitation != true +} + +data class Attachment( + val fileDetails: List<TitledText>, + val fileName: String?, + val uri: Uri?, + val hasImage: Boolean +) + +sealed class Event { + object Finish: Event() + data class StartMediaPreview(val args: MediaPreviewArgs): Event() +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ModalUrlBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ModalUrlBottomSheet.kt index 28c86b3311..54deea1c8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ModalUrlBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ModalUrlBottomSheet.kt @@ -60,8 +60,7 @@ class ModalUrlBottomSheet(private val url: String): BottomSheetDialogFragment(), override fun onStart() { super.onStart() val window = dialog?.window ?: return - val isLightMode = UiModeUtilities.isDayUiMode(requireContext()) - window.setDimAmount(if (isLightMode) 0.1f else 0.75f) + window.setDimAmount(0.6f) } override fun onClick(v: View?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/WindowUtil.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/WindowUtil.java index 4bff4e76aa..6083bb267c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/WindowUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/WindowUtil.java @@ -38,14 +38,10 @@ public final class WindowUtil { } public static void setNavigationBarColor(@NonNull Window window, @ColorInt int color) { - if (Build.VERSION.SDK_INT < 21) return; - window.setNavigationBarColor(color); } public static void setLightStatusBarFromTheme(@NonNull Activity activity) { - if (Build.VERSION.SDK_INT < 23) return; - final boolean isLightStatusBar = ThemeUtil.getThemedBoolean(activity, android.R.attr.windowLightStatusBar); if (isLightStatusBar) setLightStatusBar(activity.getWindow()); @@ -53,20 +49,14 @@ public final class WindowUtil { } public static void clearLightStatusBar(@NonNull Window window) { - if (Build.VERSION.SDK_INT < 23) return; - clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); } public static void setLightStatusBar(@NonNull Window window) { - if (Build.VERSION.SDK_INT < 23) return; - setSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR); } public static void setStatusBarColor(@NonNull Window window, @ColorInt int color) { - if (Build.VERSION.SDK_INT < 21) return; - window.setStatusBarColor(color); } 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 8f0ddd8bef..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,55 +7,40 @@ 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 import androidx.core.view.isVisible import network.loki.messenger.R import network.loki.messenger.databinding.AlbumThumbnailViewBinding -import org.session.libsession.messaging.jobs.AttachmentDownloadJob -import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.MediaPreviewActivity import org.thoughtcrime.securesms.components.CornerMask -import org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView +import org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.util.ActivityDispatcher -class AlbumThumbnailView : FrameLayout { - - private lateinit var binding: AlbumThumbnailViewBinding - +class AlbumThumbnailView : RelativeLayout { companion object { const val MAX_ALBUM_DISPLAY_SIZE = 3 } + private val binding: AlbumThumbnailViewBinding by lazy { AlbumThumbnailViewBinding.bind(this) } + // 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() - } + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val cornerMask by lazy { CornerMask(this) } private var slides: List<Slide> = listOf() private var slideSize: Int = 0 - private fun initialize() { - binding = AlbumThumbnailViewBinding.inflate(LayoutInflater.from(context), this, true) - } - - override fun dispatchDraw(canvas: Canvas?) { + override fun dispatchDraw(canvas: Canvas) { super.dispatchDraw(canvas) cornerMask.mask(canvas) } @@ -63,26 +48,25 @@ class AlbumThumbnailView : FrameLayout { // region Interaction - fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient) { + fun calculateHitObject(event: MotionEvent, mms: MmsMessageRecord, threadRecipient: Recipient, onAttachmentNeedsDownload: (Long, Long) -> Unit) { val rawXInt = event.rawX.toInt() val rawYInt = event.rawY.toInt() val eventRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt) val testRect = Rect() // test each album child - binding.albumCellContainer.findViewById<ViewGroup>(R.id.album_thumbnail_root)?.children?.forEachIndexed { index, child -> + binding.albumCellContainer.findViewById<ViewGroup>(R.id.album_thumbnail_root)?.children?.forEachIndexed forEach@{ index, child -> child.getGlobalVisibleRect(testRect) if (testRect.contains(eventRect)) { // hit intersects with this particular child - val slide = slides.getOrNull(index) ?: return + val slide = slides.getOrNull(index) ?: return@forEach // only open to downloaded images if (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED) { - // restart download here + // Restart download here (on IO thread) (slide.asAttachment() as? DatabaseAttachment)?.let { attachment -> - val attachmentId = attachment.attachmentId.rowId - JobQueue.shared.add(AttachmentDownloadJob(attachmentId, mms.getId())) + onAttachmentNeedsDownload(attachment.attachmentId.rowId, mms.getId()) } } - if (slide.isInProgress) return + if (slide.isInProgress) return@forEach ActivityDispatcher.get(context)?.dispatchIntent { context -> MediaPreviewActivity.getPreviewIntent(context, slide, mms, threadRecipient) @@ -133,7 +117,7 @@ class AlbumThumbnailView : FrameLayout { else -> R.layout.album_thumbnail_3 // three stacked with additional text } - fun getThumbnailView(position: Int): KThumbnailView = when (position) { + fun getThumbnailView(position: Int): ThumbnailView = when (position) { 0 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_1) 1 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_2) 2 -> binding.albumCellContainer.findViewById<ViewGroup>(R.id.albumCellContainer).findViewById(R.id.album_cell_3) 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/components/LinkPreviewDraftView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt index c1fce3f50b..66164f100f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/LinkPreviewDraftView.kt @@ -23,7 +23,7 @@ class LinkPreviewDraftView : LinearLayout { // Start out with the loader showing and the content view hidden binding = ViewLinkPreviewDraftBinding.inflate(LayoutInflater.from(context), this, true) binding.linkPreviewDraftContainer.isVisible = false - binding.thumbnailImageView.clipToOutline = true + binding.thumbnailImageView.root.clipToOutline = true binding.linkPreviewDraftCancelButton.setOnClickListener { cancel() } } @@ -31,10 +31,10 @@ class LinkPreviewDraftView : LinearLayout { // Hide the loader and show the content view binding.linkPreviewDraftContainer.isVisible = true binding.linkPreviewDraftLoader.isVisible = false - binding.thumbnailImageView.radius = toPx(4, resources) + binding.thumbnailImageView.root.radius = toPx(4, resources) if (linkPreview.getThumbnail().isPresent) { // This internally fetches the thumbnail - binding.thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, false) + binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), false, null) } binding.linkPreviewDraftTitleTextView.text = linkPreview.title } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt index 834b77eccb..d544263915 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/MentionCandidateView.kt @@ -28,11 +28,10 @@ class MentionCandidateView : LinearLayout { private fun update() = with(binding) { mentionCandidateNameTextView.text = mentionCandidate.displayName - profilePictureView.root.publicKey = mentionCandidate.publicKey - profilePictureView.root.displayName = mentionCandidate.displayName - profilePictureView.root.additionalPublicKey = null - profilePictureView.root.glide = glide!! - profilePictureView.root.update() + profilePictureView.publicKey = mentionCandidate.publicKey + profilePictureView.displayName = mentionCandidate.displayName + profilePictureView.additionalPublicKey = null + profilePictureView.update() if (openGroupServer != null && openGroupRoom != null) { val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", mentionCandidate.publicKey) moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.java deleted file mode 100644 index 826cfe7b3a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.java +++ /dev/null @@ -1,118 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.components; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.PorterDuff; -import androidx.annotation.Nullable; -import android.util.AttributeSet; -import android.view.View; -import android.widget.LinearLayout; - -import network.loki.messenger.R; - -public class TypingIndicatorView extends LinearLayout { - private boolean isActive; - private long startTime; - - private static final long CYCLE_DURATION = 1500; - private static final long DOT_DURATION = 600; - private static final float MIN_ALPHA = 0.4f; - private static final float MIN_SCALE = 0.75f; - - private View dot1; - private View dot2; - private View dot3; - - public TypingIndicatorView(Context context) { - super(context); - initialize(null); - } - - public TypingIndicatorView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - initialize(attrs); - } - - private void initialize(@Nullable AttributeSet attrs) { - inflate(getContext(), R.layout.view_typing_indicator, this); - - setWillNotDraw(false); - - dot1 = findViewById(R.id.typing_dot1); - dot2 = findViewById(R.id.typing_dot2); - dot3 = findViewById(R.id.typing_dot3); - - if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.TypingIndicatorView, 0, 0); - int tint = typedArray.getColor(R.styleable.TypingIndicatorView_typingIndicator_tint, Color.WHITE); - typedArray.recycle(); - - dot1.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); - dot2.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); - dot3.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY); - } - } - - @Override - protected void onDraw(Canvas canvas) { - if (!isActive) { - super.onDraw(canvas); - return; - } - - long timeInCycle = (System.currentTimeMillis() - startTime) % CYCLE_DURATION; - - render(dot1, timeInCycle, 0); - render(dot2, timeInCycle, 150); - render(dot3, timeInCycle, 300); - - super.onDraw(canvas); - postInvalidate(); - } - - private void render(View dot, long timeInCycle, long start) { - long end = start + DOT_DURATION; - long peak = start + (DOT_DURATION / 2); - - if (timeInCycle < start || timeInCycle > end) { - renderDefault(dot); - } else if (timeInCycle < peak) { - renderFadeIn(dot, timeInCycle, start); - } else { - renderFadeOut(dot, timeInCycle, peak); - } - } - - private void renderDefault(View dot) { - dot.setAlpha(MIN_ALPHA); - dot.setScaleX(MIN_SCALE); - dot.setScaleY(MIN_SCALE); - } - - private void renderFadeIn(View dot, long timeInCycle, long fadeInStart) { - float percent = (float) (timeInCycle - fadeInStart) / 300; - dot.setAlpha(MIN_ALPHA + (1 - MIN_ALPHA) * percent); - dot.setScaleX(MIN_SCALE + (1 - MIN_SCALE) * percent); - dot.setScaleY(MIN_SCALE + (1 - MIN_SCALE) * percent); - } - - private void renderFadeOut(View dot, long timeInCycle, long fadeOutStart) { - float percent = (float) (timeInCycle - fadeOutStart) / 300; - dot.setAlpha(1 - (1 - MIN_ALPHA) * percent); - dot.setScaleX(1 - (1 - MIN_SCALE) * percent); - dot.setScaleY(1 - (1 - MIN_SCALE) * percent); - } - - public void startAnimation() { - isActive = true; - startTime = System.currentTimeMillis(); - - postInvalidate(); - } - - public void stopAnimation() { - isActive = false; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.kt new file mode 100644 index 0000000000..d1310bffba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorView.kt @@ -0,0 +1,105 @@ +package org.thoughtcrime.securesms.conversation.v2.components + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.PorterDuff +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import network.loki.messenger.R +import network.loki.messenger.databinding.ViewTypingIndicatorBinding + +class TypingIndicatorView : LinearLayout { + companion object { + private const val CYCLE_DURATION: Long = 1500 + private const val DOT_DURATION: Long = 600 + private const val MIN_ALPHA = 0.4f + private const val MIN_SCALE = 0.75f + } + + private val binding: ViewTypingIndicatorBinding by lazy { + val binding = ViewTypingIndicatorBinding.bind(this) + + if (tint != -1) { + binding.typingDot1.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY) + binding.typingDot2.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY) + binding.typingDot3.getBackground().setColorFilter(tint, PorterDuff.Mode.MULTIPLY) + } + + return@lazy binding + } + + private var isActive = false + private var startTime: Long = 0 + private var tint: Int = -1 + + constructor(context: Context) : super(context) { initialize(null) } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) } + + private fun initialize(attrs: AttributeSet?) { + setWillNotDraw(false) + + if (attrs != null) { + val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.TypingIndicatorView, 0, 0) + this.tint = typedArray.getColor(R.styleable.TypingIndicatorView_typingIndicator_tint, Color.WHITE) + typedArray.recycle() + } + } + + override fun onDraw(canvas: Canvas) { + if (!isActive) { + super.onDraw(canvas) + return + } + val timeInCycle = (System.currentTimeMillis() - startTime) % CYCLE_DURATION + render(binding.typingDot1, timeInCycle, 0) + render(binding.typingDot2, timeInCycle, 150) + render(binding.typingDot3, timeInCycle, 300) + super.onDraw(canvas) + postInvalidate() + } + + private fun render(dot: View?, timeInCycle: Long, start: Long) { + val end = start + DOT_DURATION + val peak = start + DOT_DURATION / 2 + if (timeInCycle < start || timeInCycle > end) { + renderDefault(dot) + } else if (timeInCycle < peak) { + renderFadeIn(dot, timeInCycle, start) + } else { + renderFadeOut(dot, timeInCycle, peak) + } + } + + private fun renderDefault(dot: View?) { + dot!!.alpha = MIN_ALPHA + dot.scaleX = MIN_SCALE + dot.scaleY = MIN_SCALE + } + + private fun renderFadeIn(dot: View?, timeInCycle: Long, fadeInStart: Long) { + val percent = (timeInCycle - fadeInStart).toFloat() / 300 + dot!!.alpha = MIN_ALPHA + (1 - MIN_ALPHA) * percent + dot.scaleX = MIN_SCALE + (1 - MIN_SCALE) * percent + dot.scaleY = MIN_SCALE + (1 - MIN_SCALE) * percent + } + + private fun renderFadeOut(dot: View?, timeInCycle: Long, fadeOutStart: Long) { + val percent = (timeInCycle - fadeOutStart).toFloat() / 300 + dot!!.alpha = 1 - (1 - MIN_ALPHA) * percent + dot.scaleX = 1 - (1 - MIN_SCALE) * percent + dot.scaleY = 1 - (1 - MIN_SCALE) * percent + } + + fun startAnimation() { + isActive = true + startTime = System.currentTimeMillis() + postInvalidate() + } + + fun stopAnimation() { + isActive = false + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt index 768d49146e..3077d227e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt @@ -19,7 +19,7 @@ class TypingIndicatorViewContainer : LinearLayout { } fun setTypists(typists: List<Recipient>) { - if (typists.isEmpty()) { binding.typingIndicator.stopAnimation(); return } - binding.typingIndicator.startAnimation() + if (typists.isEmpty()) { binding.typingIndicator.root.stopAnimation(); return } + binding.typingIndicator.root.startAnimation() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt index 39ca7c6913..c0ff1cbb1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/BlockedDialog.kt @@ -1,41 +1,42 @@ package org.thoughtcrime.securesms.conversation.v2.dialogs +import android.app.Dialog +import android.content.Context import android.graphics.Typeface +import android.os.Bundle import android.text.Spannable import android.text.SpannableStringBuilder import android.text.style.StyleSpan -import android.view.LayoutInflater -import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment import network.loki.messenger.R -import network.loki.messenger.databinding.DialogBlockedBinding +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.dependencies.DatabaseComponent /** Shown upon sending a message to a user that's blocked. */ -class BlockedDialog(private val recipient: Recipient) : BaseDialog() { +class BlockedDialog(private val recipient: Recipient, private val context: Context) : DialogFragment() { - override fun setContentView(builder: AlertDialog.Builder) { - val binding = DialogBlockedBinding.inflate(LayoutInflater.from(requireContext())) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase() val sessionID = recipient.address.toString() val contact = contactDB.getContactWithSessionID(sessionID) val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID - val title = resources.getString(R.string.dialog_blocked_title, name) - binding.blockedTitleTextView.text = title + val explanation = resources.getString(R.string.dialog_blocked_explanation, name) val spannable = SpannableStringBuilder(explanation) val startIndex = explanation.indexOf(name) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - binding.blockedExplanationTextView.text = spannable - binding.cancelButton.setOnClickListener { dismiss() } - binding.unblockButton.setOnClickListener { unblock() } - builder.setView(binding.root) + + title(resources.getString(R.string.dialog_blocked_title, name)) + text(spannable) + button(R.string.ConversationActivity_unblock) { unblock() } + cancelButton { dismiss() } } private fun unblock() { - DatabaseComponent.get(requireContext()).recipientDatabase().setBlocked(recipient, false) + MessagingModuleConfiguration.shared.storage.setBlocked(listOf(recipient), false) dismiss() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt index 42cca1ad3e..5edd63f100 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/DownloadDialog.kt @@ -1,19 +1,19 @@ package org.thoughtcrime.securesms.conversation.v2.dialogs +import android.app.Dialog import android.graphics.Typeface +import android.os.Bundle import android.text.Spannable import android.text.SpannableStringBuilder import android.text.style.StyleSpan -import android.view.LayoutInflater -import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R -import network.loki.messenger.databinding.DialogDownloadBinding import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentDownloadJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.dependencies.DatabaseComponent import javax.inject.Inject @@ -21,25 +21,24 @@ import javax.inject.Inject /** Shown when receiving media from a contact for the first time, to confirm that * they are to be trusted and files sent by them are to be downloaded. */ @AndroidEntryPoint -class DownloadDialog(private val recipient: Recipient) : BaseDialog() { +class DownloadDialog(private val recipient: Recipient) : DialogFragment() { @Inject lateinit var contactDB: SessionContactDatabase - override fun setContentView(builder: AlertDialog.Builder) { - val binding = DialogDownloadBinding.inflate(LayoutInflater.from(requireContext())) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { val sessionID = recipient.address.toString() val contact = contactDB.getContactWithSessionID(sessionID) val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID - val title = resources.getString(R.string.dialog_download_title, name) - binding.downloadTitleTextView.text = title + title(resources.getString(R.string.dialog_download_title, name)) + val explanation = resources.getString(R.string.dialog_download_explanation, name) val spannable = SpannableStringBuilder(explanation) val startIndex = explanation.indexOf(name) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - binding.downloadExplanationTextView.text = spannable - binding.cancelButton.setOnClickListener { dismiss() } - binding.downloadButton.setOnClickListener { trust() } - builder.setView(binding.root) + text(spannable) + + button(R.string.dialog_download_button_title, R.string.AccessibilityId_download_media) { trust() } + cancelButton { dismiss() } } private fun trust() { @@ -50,4 +49,4 @@ class DownloadDialog(private val recipient: Recipient) : BaseDialog() { JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY) dismiss() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt index 444c389e04..a886e89192 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/JoinOpenGroupDialog.kt @@ -1,46 +1,42 @@ package org.thoughtcrime.securesms.conversation.v2.dialogs +import android.app.Dialog import android.graphics.Typeface +import android.os.Bundle import android.text.Spannable import android.text.SpannableStringBuilder import android.text.style.StyleSpan -import android.view.LayoutInflater import android.widget.Toast -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.DialogFragment import network.loki.messenger.R -import network.loki.messenger.databinding.DialogJoinOpenGroupBinding import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.utilities.OpenGroupUrlParser import org.session.libsignal.utilities.ThreadUtils -import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities /** Shown upon tapping an open group invitation. */ -class JoinOpenGroupDialog(private val name: String, private val url: String) : BaseDialog() { +class JoinOpenGroupDialog(private val name: String, private val url: String) : DialogFragment() { - override fun setContentView(builder: AlertDialog.Builder) { - val binding = DialogJoinOpenGroupBinding.inflate(LayoutInflater.from(requireContext())) - val title = resources.getString(R.string.dialog_join_open_group_title, name) - binding.joinOpenGroupTitleTextView.text = title + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { + title(resources.getString(R.string.dialog_join_open_group_title, name)) val explanation = resources.getString(R.string.dialog_join_open_group_explanation, name) val spannable = SpannableStringBuilder(explanation) val startIndex = explanation.indexOf(name) spannable.setSpan(StyleSpan(Typeface.BOLD), startIndex, startIndex + name.count(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - binding.joinOpenGroupExplanationTextView.text = spannable - binding.cancelButton.setOnClickListener { dismiss() } - binding.joinButton.setOnClickListener { join() } - builder.setView(binding.root) + text(spannable) + cancelButton { dismiss() } + button(R.string.open_group_invitation_view__join_accessibility_description) { join() } } private fun join() { val openGroup = OpenGroupUrlParser.parseUrl(url) - val activity = requireContext() as AppCompatActivity + val activity = requireActivity() ThreadUtils.queue { try { - OpenGroupManager.add(openGroup.server, openGroup.room, openGroup.serverPublicKey, activity) - MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server) + openGroup.apply { OpenGroupManager.add(server, room, serverPublicKey, activity) } + MessagingModuleConfiguration.shared.storage.onOpenGroupAdded(openGroup.server, openGroup.room) ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(activity) } catch (e: Exception) { Toast.makeText(activity, R.string.activity_join_public_chat_error, Toast.LENGTH_SHORT).show() @@ -48,4 +44,4 @@ class JoinOpenGroupDialog(private val name: String, private val url: String) : B } dismiss() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt index a16ca86f79..996dd41f94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/LinkPreviewDialog.kt @@ -1,20 +1,21 @@ package org.thoughtcrime.securesms.conversation.v2.dialogs -import android.view.LayoutInflater -import androidx.appcompat.app.AlertDialog -import network.loki.messenger.databinding.DialogLinkPreviewBinding +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences -import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.createSessionDialog /** Shown the first time the user inputs a URL that could generate a link preview, to * let them know that Session offers the ability to send and receive link previews. */ -class LinkPreviewDialog(private val onEnabled: () -> Unit) : BaseDialog() { +class LinkPreviewDialog(private val onEnabled: () -> Unit) : DialogFragment() { - override fun setContentView(builder: AlertDialog.Builder) { - val binding = DialogLinkPreviewBinding.inflate(LayoutInflater.from(requireContext())) - binding.cancelButton.setOnClickListener { dismiss() } - binding.enableLinkPreviewsButton.setOnClickListener { enable() } - builder.setView(binding.root) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { + title(R.string.dialog_link_preview_title) + text(R.string.dialog_link_preview_explanation) + button(R.string.dialog_link_preview_enable_button_title) { enable() } + cancelButton { dismiss() } } private fun enable() { @@ -22,4 +23,4 @@ class LinkPreviewDialog(private val onEnabled: () -> Unit) : BaseDialog() { dismiss() onEnabled() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt index f51261d499..6abb0814d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt @@ -1,22 +1,23 @@ package org.thoughtcrime.securesms.conversation.v2.dialogs -import android.view.LayoutInflater -import androidx.appcompat.app.AlertDialog -import network.loki.messenger.databinding.DialogSendSeedBinding -import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import network.loki.messenger.R +import org.thoughtcrime.securesms.createSessionDialog /** Shown if the user is about to send their recovery phrase to someone. */ -class SendSeedDialog(private val proceed: (() -> Unit)? = null) : BaseDialog() { +class SendSeedDialog(private val proceed: (() -> Unit)? = null) : DialogFragment() { - override fun setContentView(builder: AlertDialog.Builder) { - val binding = DialogSendSeedBinding.inflate(LayoutInflater.from(requireContext())) - binding.cancelButton.setOnClickListener { dismiss() } - binding.sendSeedButton.setOnClickListener { send() } - builder.setView(binding.root) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { + title(R.string.dialog_send_seed_title) + text(R.string.dialog_send_seed_explanation) + button(R.string.dialog_send_seed_send_button_title) { send() } + cancelButton() } private fun send() { proceed?.invoke() dismiss() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index 7ac70b843a..3544f11b10 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -37,6 +37,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li private val vMargin by lazy { toDp(4, resources) } private val minHeight by lazy { toPx(56, resources) } private var linkPreviewDraftView: LinkPreviewDraftView? = null + private var quoteView: QuoteView? = null var delegate: InputBarDelegate? = null var additionalContentHeight = 0 var quote: MessageRecord? = null @@ -57,9 +58,9 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li val attachmentButtonsContainerHeight: Int get() = binding.attachmentsButtonContainer.height - private val attachmentsButton by lazy { InputBarButton(context, R.drawable.ic_plus_24) } - private val microphoneButton by lazy { InputBarButton(context, R.drawable.ic_microphone) } - private val sendButton by lazy { InputBarButton(context, R.drawable.ic_arrow_up, true) } + private val attachmentsButton by lazy { InputBarButton(context, R.drawable.ic_plus_24).apply { contentDescription = context.getString(R.string.AccessibilityId_attachments_button)} } + private val microphoneButton by lazy { InputBarButton(context, R.drawable.ic_microphone).apply { contentDescription = context.getString(R.string.AccessibilityId_microphone_button)} } + private val sendButton by lazy { InputBarButton(context, R.drawable.ic_arrow_up, true).apply { contentDescription = context.getString(R.string.AccessibilityId_send_message_button)} } // region Lifecycle constructor(context: Context) : super(context) { initialize() } @@ -98,7 +99,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE binding.inputBarEditText.inputType = binding.inputBarEditText.inputType or - InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + InputType.TYPE_TEXT_FLAG_CAP_SENTENCES } val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0 binding.inputBarEditText.imeOptions = binding.inputBarEditText.imeOptions or incognitoFlag // Always use incognito keyboard if setting enabled @@ -138,53 +139,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/input_bar/mentions/MentionCandidateView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt index a21ba1b502..2d8f745967 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidateView.kt @@ -28,11 +28,10 @@ class MentionCandidateView : RelativeLayout { private fun update() = with(binding) { mentionCandidateNameTextView.text = candidate.displayName - profilePictureView.root.publicKey = candidate.publicKey - profilePictureView.root.displayName = candidate.displayName - profilePictureView.root.additionalPublicKey = null - profilePictureView.root.glide = glide!! - profilePictureView.root.update() + profilePictureView.publicKey = candidate.publicKey + profilePictureView.displayName = candidate.displayName + profilePictureView.additionalPublicKey = null + profilePictureView.update() if (openGroupServer != null && openGroupRoom != null) { val isUserModerator = OpenGroupManager.isUserModerator(context, "$openGroupRoom.$openGroupServer", candidate.publicKey) moderatorIconImageView.visibility = if (isUserModerator) View.VISIBLE else View.GONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt index 401ccaa3c2..e62f7f8f85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/mentions/MentionCandidatesView.kt @@ -7,6 +7,7 @@ import android.view.ViewGroup import android.widget.BaseAdapter import android.widget.ListView import dagger.hilt.android.AndroidEntryPoint +import network.loki.messenger.R import org.session.libsession.messaging.mentions.Mention import org.thoughtcrime.securesms.database.LokiThreadDatabase import org.thoughtcrime.securesms.mms.GlideRequests @@ -41,7 +42,9 @@ class MentionCandidatesView(context: Context, attrs: AttributeSet?, defStyleAttr override fun getItem(position: Int): Mention { return candidates[position] } override fun getView(position: Int, cellToBeReused: View?, parent: ViewGroup): View { - val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context) + val cell = cellToBeReused as MentionCandidateView? ?: MentionCandidateView(context).apply { + contentDescription = context.getString(R.string.AccessibilityId_contact) + } val mentionCandidate = getItem(position) cell.glide = glide cell.candidate = mentionCandidate 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 cab24ce8be..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,17 +65,19 @@ 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.recipient.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 && firstMessage.isFailed) + menu.findItem(R.id.menu_message_details).isVisible = selectedItems.size == 1 // Resend menu.findItem(R.id.menu_context_resend).isVisible = (selectedItems.size == 1 && firstMessage.isFailed) + // Resync + menu.findItem(R.id.menu_context_resync).isVisible = (selectedItems.size == 1 && firstMessage.isSyncFailed) // Save media menu.findItem(R.id.menu_context_save_attachment).isVisible = (selectedItems.size == 1 && 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 { @@ -90,6 +92,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p R.id.menu_context_ban_and_delete_all -> delegate?.banAndDeleteAll(selectedItems) R.id.menu_context_copy -> delegate?.copyMessages(selectedItems) R.id.menu_context_copy_public_key -> delegate?.copySessionID(selectedItems) + R.id.menu_context_resync -> delegate?.resyncMessage(selectedItems) R.id.menu_context_resend -> delegate?.resendMessage(selectedItems) R.id.menu_message_details -> delegate?.showMessageDetail(selectedItems) R.id.menu_context_save_attachment -> delegate?.saveAttachment(selectedItems) @@ -101,6 +104,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p override fun onDestroyActionMode(mode: ActionMode) { adapter.selectedItems.clear() adapter.notifyDataSetChanged() + delegate?.destroyActionMode() } } @@ -112,8 +116,10 @@ interface ConversationActionModeCallbackDelegate { fun banAndDeleteAll(messages: Set<MessageRecord>) fun copyMessages(messages: Set<MessageRecord>) fun copySessionID(messages: Set<MessageRecord>) + fun resyncMessage(messages: Set<MessageRecord>) fun resendMessage(messages: Set<MessageRecord>) fun showMessageDetail(messages: Set<MessageRecord>) fun saveAttachment(messages: Set<MessageRecord>) fun reply(messages: Set<MessageRecord>) + fun destroyActionMode() } \ No newline at end of file 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 663dd2e255..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,17 +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.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.widget.SearchView @@ -25,15 +19,12 @@ 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 import org.thoughtcrime.securesms.MediaOverviewActivity -import org.thoughtcrime.securesms.MuteDialog import org.thoughtcrime.securesms.ShortcutLauncherActivity import org.thoughtcrime.securesms.calls.WebRtcCallActivity import org.thoughtcrime.securesms.contacts.SelectContactsActivity @@ -44,6 +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.showMuteDialog +import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.BitmapUtil import java.io.IOException @@ -53,36 +46,26 @@ 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)) { - if (thread.expireMessages > 0) { - inflater.inflate(R.menu.menu_conversation_expiration_on, menu) - val item = menu.findItem(R.id.menu_expiring_messages) - val actionView = item.actionView - val iconView = actionView.findViewById<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) { + inflater.inflate(R.menu.menu_conversation_copy_session_id, menu) } // One-on-one chat menu (options that should only be present for one-on-one chats) if (thread.isContactRecipient) { if (thread.isBlocked) { inflater.inflate(R.menu.menu_conversation_unblock, menu) - } else { + } else if (!thread.isLocalNumber) { inflater.inflate(R.menu.menu_conversation_block, menu) } } @@ -105,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) } @@ -148,12 +131,12 @@ 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) } R.id.menu_copy_session_id -> { copySessionID(context, thread) } + R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(context, thread) } R.id.menu_edit_group -> { editClosedGroup(context, thread) } R.id.menu_leave_group -> { leaveClosedGroup(context, thread) } R.id.menu_invite_to_open_group -> { inviteContacts(context, thread) } @@ -180,26 +163,23 @@ object ConversationMenuHelper { private fun call(context: Context, thread: Recipient) { if (!TextSecurePreferences.isCallNotificationsEnabled(context)) { - AlertDialog.Builder(context) - .setTitle(R.string.ConversationActivity_call_title) - .setMessage(R.string.ConversationActivity_call_prompt) - .setPositiveButton(R.string.activity_settings_title) { _, _ -> - val intent = Intent(context, PrivacySettingsActivity::class.java) - context.startActivity(intent) + context.showSessionDialog { + title(R.string.ConversationActivity_call_title) + text(R.string.ConversationActivity_call_prompt) + button(R.string.activity_settings_title, R.string.AccessibilityId_settings) { + Intent(context, PrivacySettingsActivity::class.java).let(context::startActivity) } - .setNeutralButton(R.string.cancel) { d, _ -> - d.dismiss() - }.show() + cancelButton() + } return } - val service = WebRtcCallService.createCall(context, thread) - context.startService(service) + WebRtcCallService.createCall(context, thread) + .let(context::startService) - val activity = Intent(context, WebRtcCallActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - context.startActivity(activity) + Intent(context, WebRtcCallActivity::class.java) + .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } + .let(context::startActivity) } @@ -207,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 @@ -225,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)) @@ -241,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) { @@ -270,6 +252,12 @@ object ConversationMenuHelper { listener.copySessionID(thread.address.toString()) } + private fun copyOpenGroupUrl(context: Context, thread: Recipient) { + if (!thread.isCommunityRecipient) { return } + val listener = context as? ConversationMenuListener ?: return + listener.copyOpenGroupUrl(thread) + } + private fun editClosedGroup(context: Context, thread: Recipient) { if (!thread.isClosedGroupRecipient) { return } val intent = Intent(context, EditClosedGroupActivity::class.java) @@ -280,9 +268,7 @@ object ConversationMenuHelper { private fun leaveClosedGroup(context: Context, thread: Recipient) { if (!thread.isClosedGroupRecipient) { return } - val builder = AlertDialog.Builder(context) - builder.setTitle(context.resources.getString(R.string.ConversationActivity_leave_group)) - builder.setCancelable(true) + val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull() val admins = group.admins val sessionID = TextSecurePreferences.getLocalNumber(context) @@ -292,33 +278,29 @@ object ConversationMenuHelper { } else { context.resources.getString(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group) } - builder.setMessage(message) - builder.setPositiveButton(R.string.yes) { _, _ -> - var groupPublicKey: String? - var isClosedGroup: Boolean - try { - groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() - isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) - } catch (e: IOException) { - groupPublicKey = null - isClosedGroup = false - } - try { - if (isClosedGroup) { - MessageSender.leave(groupPublicKey!!, true) - } else { - Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show() + + fun onLeaveFailed() = Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show() + + context.showSessionDialog { + title(R.string.ConversationActivity_leave_group) + text(message) + button(R.string.yes) { + try { + val groupPublicKey = doubleDecodeGroupID(thread.address.toString()).toHexString() + val isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) + + if (isClosedGroup) MessageSender.leave(groupPublicKey, notifyUser = false) + else onLeaveFailed() + } catch (e: Exception) { + onLeaveFailed() } - } catch (e: Exception) { - Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show() } + button(R.string.no) } - builder.setNegativeButton(R.string.no, null) - builder.show() } private fun inviteContacts(context: Context, thread: Recipient) { - 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) @@ -329,7 +311,7 @@ object ConversationMenuHelper { } private fun mute(context: Context, thread: Recipient) { - MuteDialog.show(ContextThemeWrapper(context, context.theme)) { until: Long -> + showMuteDialog(ContextThemeWrapper(context, context.theme)) { until -> DatabaseComponent.get(context).recipientDatabase().setMuted(thread, until) } } @@ -344,7 +326,8 @@ object ConversationMenuHelper { fun block(deleteThread: Boolean = false) fun unblock() fun copySessionID(sessionId: String) - fun showExpiringMessagesDialog(thread: Recipient) + fun copyOpenGroupUrl(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 a4e4a52d5b..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,49 +3,80 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater -import android.view.View import android.widget.LinearLayout import androidx.core.content.res.ResourcesCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewControlMessageBinding +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessages +import org.thoughtcrime.securesms.conversation.disappearingmessages.expiryMode import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import javax.inject.Inject +@AndroidEntryPoint class ControlMessageView : LinearLayout { - private 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.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 -> { val drawable = when { @@ -54,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/EmojiReactionsView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.java deleted file mode 100644 index 6d16f1f421..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.java +++ /dev/null @@ -1,346 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.messages; - -import android.content.Context; -import android.content.res.TypedArray; -import android.os.Handler; -import android.os.Looper; -import android.util.AttributeSet; -import android.view.HapticFeedbackConstants; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.widget.LinearLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.constraintlayout.widget.Group; -import androidx.core.content.ContextCompat; - -import com.google.android.flexbox.FlexboxLayout; -import com.google.android.flexbox.JustifyContent; - -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.ThemeUtil; -import org.thoughtcrime.securesms.components.emoji.EmojiImageView; -import org.thoughtcrime.securesms.components.emoji.EmojiUtil; -import org.thoughtcrime.securesms.conversation.v2.ViewUtil; -import org.thoughtcrime.securesms.database.model.MessageId; -import org.thoughtcrime.securesms.database.model.ReactionRecord; -import org.thoughtcrime.securesms.util.NumberUtil; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import network.loki.messenger.R; - -public class EmojiReactionsView extends LinearLayout implements View.OnTouchListener { - - // Normally 6dp, but we have 1dp left+right margin on the pills themselves - private final int OUTER_MARGIN = ViewUtil.dpToPx(2); - private static final int DEFAULT_THRESHOLD = 5; - - private List<ReactionRecord> records; - private long messageId; - private ViewGroup container; - private Group showLess; - private VisibleMessageViewDelegate delegate; - private Handler gestureHandler = new Handler(Looper.getMainLooper()); - private Runnable pressCallback; - private Runnable longPressCallback; - private long onDownTimestamp = 0; - private static long longPressDurationThreshold = 250; - private static long maxDoubleTapInterval = 200; - private boolean extended = false; - - public EmojiReactionsView(Context context) { - super(context); - init(null); - } - - public EmojiReactionsView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - init(attrs); - } - - private void init(@Nullable AttributeSet attrs) { - inflate(getContext(), R.layout.view_emoji_reactions, this); - - this.container = findViewById(R.id.layout_emoji_container); - this.showLess = findViewById(R.id.group_show_less); - - records = new ArrayList<>(); - - if (attrs != null) { - TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiReactionsView, 0, 0); - typedArray.recycle(); - } - } - - public void clear() { - this.records.clear(); - container.removeAllViews(); - } - - public void setReactions(long messageId, @NonNull List<ReactionRecord> records, boolean outgoing, VisibleMessageViewDelegate delegate) { - this.delegate = delegate; - if (records.equals(this.records)) { - return; - } - - FlexboxLayout containerLayout = (FlexboxLayout) this.container; - containerLayout.setJustifyContent(outgoing ? JustifyContent.FLEX_END : JustifyContent.FLEX_START); - this.records.clear(); - this.records.addAll(records); - if (this.messageId != messageId) { - extended = false; - } - this.messageId = messageId; - - displayReactions(extended ? Integer.MAX_VALUE : DEFAULT_THRESHOLD); - } - - @Override - public boolean onTouch(View v, MotionEvent event) { - if (v.getTag() == null) return false; - - Reaction reaction = (Reaction) v.getTag(); - int action = event.getAction(); - if (action == MotionEvent.ACTION_DOWN) onDown(new MessageId(reaction.messageId, reaction.isMms)); - else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback(); - else if (action == MotionEvent.ACTION_UP) onUp(reaction); - return true; - } - - private void displayReactions(int threshold) { - String userPublicKey = TextSecurePreferences.getLocalNumber(getContext()); - List<Reaction> reactions = buildSortedReactionsList(records, userPublicKey, threshold); - - container.removeAllViews(); - LinearLayout overflowContainer = new LinearLayout(getContext()); - overflowContainer.setOrientation(LinearLayout.HORIZONTAL); - int innerPadding = ViewUtil.dpToPx(4); - overflowContainer.setPaddingRelative(innerPadding,innerPadding,innerPadding,innerPadding); - - int pixelSize = ViewUtil.dpToPx(1); - - for (Reaction reaction : reactions) { - if (container.getChildCount() + 1 >= DEFAULT_THRESHOLD && threshold != Integer.MAX_VALUE && reactions.size() > threshold) { - if (overflowContainer.getParent() == null) { - container.addView(overflowContainer); - MarginLayoutParams overflowParams = (MarginLayoutParams) overflowContainer.getLayoutParams(); - overflowParams.height = ViewUtil.dpToPx(26); - overflowParams.setMargins(pixelSize, pixelSize, pixelSize, pixelSize); - overflowContainer.setLayoutParams(overflowParams); - overflowContainer.setBackground(ContextCompat.getDrawable(getContext(), R.drawable.reaction_pill_background)); - } - View pill = buildPill(getContext(), this, reaction, true); - pill.setOnClickListener(v -> { - extended = true; - displayReactions(Integer.MAX_VALUE); - }); - pill.findViewById(R.id.reactions_pill_count).setVisibility(View.GONE); - pill.findViewById(R.id.reactions_pill_spacer).setVisibility(View.GONE); - overflowContainer.addView(pill); - } else { - View pill = buildPill(getContext(), this, reaction, false); - pill.setTag(reaction); - pill.setOnTouchListener(this); - MarginLayoutParams params = (MarginLayoutParams) pill.getLayoutParams(); - params.setMargins(pixelSize, pixelSize, pixelSize, pixelSize); - pill.setLayoutParams(params); - container.addView(pill); - } - } - - int overflowChildren = overflowContainer.getChildCount(); - int negativeMargin = ViewUtil.dpToPx(-8); - for (int i = 0; i < overflowChildren; i++) { - View child = overflowContainer.getChildAt(i); - MarginLayoutParams childParams = (MarginLayoutParams) child.getLayoutParams(); - if ((i == 0 && overflowChildren > 1) || i + 1 < overflowChildren) { - // if first and there is more than one child, or we are not the last child then set negative right margin - childParams.setMargins(0,0, negativeMargin, 0); - child.setLayoutParams(childParams); - } - } - - if (threshold == Integer.MAX_VALUE) { - showLess.setVisibility(VISIBLE); - for (int id : showLess.getReferencedIds()) { - findViewById(id).setOnClickListener(view -> { - extended = false; - displayReactions(DEFAULT_THRESHOLD); - }); - } - } else { - showLess.setVisibility(GONE); - } - } - - private void onReactionClicked(Reaction reaction) { - if (reaction.messageId != 0) { - MessageId messageId = new MessageId(reaction.messageId, reaction.isMms); - delegate.onReactionClicked(reaction.emoji, messageId, reaction.userWasSender); - } - } - - private static @NonNull List<Reaction> buildSortedReactionsList(@NonNull List<ReactionRecord> records, String userPublicKey, int threshold) { - Map<String, Reaction> counters = new LinkedHashMap<>(); - - for (ReactionRecord record : records) { - String baseEmoji = EmojiUtil.getCanonicalRepresentation(record.getEmoji()); - Reaction info = counters.get(baseEmoji); - - if (info == null) { - info = new Reaction(record.getMessageId(), record.isMms(), record.getEmoji(), record.getCount(), record.getSortId(), record.getDateReceived(), userPublicKey.equals(record.getAuthor())); - } else { - info.update(record.getEmoji(), record.getCount(), record.getDateReceived(), userPublicKey.equals(record.getAuthor())); - } - - counters.put(baseEmoji, info); - } - - List<Reaction> reactions = new ArrayList<>(counters.values()); - - Collections.sort(reactions, Collections.reverseOrder()); - - if (reactions.size() >= threshold + 2 && threshold != Integer.MAX_VALUE) { - List<Reaction> shortened = new ArrayList<>(threshold + 2); - shortened.addAll(reactions.subList(0, threshold + 2)); - return shortened; - } else { - return reactions; - } - } - - private static View buildPill(@NonNull Context context, @NonNull ViewGroup parent, @NonNull Reaction reaction, boolean isCompact) { - View root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false); - EmojiImageView emojiView = root.findViewById(R.id.reactions_pill_emoji); - TextView countView = root.findViewById(R.id.reactions_pill_count); - View spacer = root.findViewById(R.id.reactions_pill_spacer); - - if (isCompact) { - root.setPaddingRelative(1,1,1,1); - ViewGroup.LayoutParams layoutParams = root.getLayoutParams(); - layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT; - root.setLayoutParams(layoutParams); - } - - if (reaction.emoji != null) { - emojiView.setImageEmoji(reaction.emoji); - - if (reaction.count >= 1) { - countView.setText(NumberUtil.getFormattedNumber(reaction.count)); - } else { - countView.setVisibility(GONE); - spacer.setVisibility(GONE); - } - } else { - emojiView.setVisibility(GONE); - spacer.setVisibility(GONE); - countView.setText(context.getString(R.string.ReactionsConversationView_plus, reaction.count)); - } - - if (reaction.userWasSender && !isCompact) { - root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected)); - countView.setTextColor(ThemeUtil.getThemedColor(context, R.attr.reactionsPillSelectedTextColor)); - } else { - if (!isCompact) { - root.setBackground(ContextCompat.getDrawable(context, R.drawable.reaction_pill_background)); - } - } - - return root; - } - - private void onDown(MessageId messageId) { - removeLongPressCallback(); - Runnable newLongPressCallback = () -> { - performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); - if (delegate != null) { - delegate.onReactionLongClicked(messageId); - } - }; - this.longPressCallback = newLongPressCallback; - gestureHandler.postDelayed(newLongPressCallback, longPressDurationThreshold); - onDownTimestamp = new Date().getTime(); - } - - private void removeLongPressCallback() { - if (longPressCallback != null) { - gestureHandler.removeCallbacks(longPressCallback); - } - } - - private void onUp(Reaction reaction) { - if ((new Date().getTime() - onDownTimestamp) < longPressDurationThreshold) { - removeLongPressCallback(); - if (pressCallback != null) { - gestureHandler.removeCallbacks(pressCallback); - this.pressCallback = null; - } else { - Runnable newPressCallback = () -> { - onReactionClicked(reaction); - pressCallback = null; - }; - this.pressCallback = newPressCallback; - gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval); - } - } - } - - private static class Reaction implements Comparable<Reaction> { - private final long messageId; - private final boolean isMms; - private String emoji; - private long count; - private long sortIndex; - private long lastSeen; - private boolean userWasSender; - - Reaction(long messageId, boolean isMms, @Nullable String emoji, long count, long sortIndex, long lastSeen, boolean userWasSender) { - this.messageId = messageId; - this.isMms = isMms; - this.emoji = emoji; - this.count = count; - this.sortIndex = sortIndex; - this.lastSeen = lastSeen; - this.userWasSender = userWasSender; - } - - void update(@NonNull String emoji, long count, long lastSeen, boolean userWasSender) { - if (!this.userWasSender) { - if (userWasSender || lastSeen > this.lastSeen) { - this.emoji = emoji; - } - } - - this.count = this.count + count; - this.lastSeen = Math.max(this.lastSeen, lastSeen); - this.userWasSender = this.userWasSender || userWasSender; - } - - @NonNull Reaction merge(@NonNull Reaction other) { - this.count = this.count + other.count; - this.lastSeen = Math.max(this.lastSeen, other.lastSeen); - this.userWasSender = this.userWasSender || other.userWasSender; - return this; - } - - @Override - public int compareTo(Reaction rhs) { - Reaction lhs = this; - if (lhs.count == rhs.count ) { - return Long.compare(lhs.sortIndex, rhs.sortIndex); - } else { - return Long.compare(lhs.count, rhs.count); - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt new file mode 100644 index 0000000000..49e4b1044f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/EmojiReactionsView.kt @@ -0,0 +1,291 @@ +package org.thoughtcrime.securesms.conversation.v2.messages + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.* +import android.view.View.OnTouchListener +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import com.google.android.flexbox.JustifyContent +import network.loki.messenger.R +import network.loki.messenger.databinding.ViewEmojiReactionsBinding +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.EmojiUtil +import org.thoughtcrime.securesms.conversation.v2.ViewUtil +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.util.NumberUtil.getFormattedNumber +import java.util.* + +class EmojiReactionsView : ConstraintLayout, OnTouchListener { + companion object { + private const val DEFAULT_THRESHOLD = 5 + private const val longPressDurationThreshold: Long = 250 + private const val maxDoubleTapInterval: Long = 200 + } + + private val binding: ViewEmojiReactionsBinding by lazy { ViewEmojiReactionsBinding.bind(this) } + + // Normally 6dp, but we have 1dp left+right margin on the pills themselves + private val OUTER_MARGIN = ViewUtil.dpToPx(2) + private var records: MutableList<ReactionRecord>? = null + private var messageId: Long = 0 + private var delegate: VisibleMessageViewDelegate? = null + private val gestureHandler = Handler(Looper.getMainLooper()) + private var pressCallback: Runnable? = null + private var longPressCallback: Runnable? = null + private var onDownTimestamp: Long = 0 + private var extended = false + + constructor(context: Context) : super(context) { init(null) } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { init(attrs) } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { init(attrs) } + + private fun init(attrs: AttributeSet?) { + records = ArrayList() + + if (attrs != null) { + val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.EmojiReactionsView, 0, 0) + typedArray.recycle() + } + } + + fun clear() { + records!!.clear() + binding.layoutEmojiContainer.removeAllViews() + } + + fun setReactions(messageId: Long, records: List<ReactionRecord>, outgoing: Boolean, delegate: VisibleMessageViewDelegate?) { + this.delegate = delegate + if (records == this.records) { + return + } + + binding.layoutEmojiContainer.justifyContent = if (outgoing) JustifyContent.FLEX_END else JustifyContent.FLEX_START + this.records!!.clear() + this.records!!.addAll(records) + if (this.messageId != messageId) { + extended = false + } + this.messageId = messageId + displayReactions(if (extended) Int.MAX_VALUE else DEFAULT_THRESHOLD) + } + + override fun onTouch(v: View, event: MotionEvent): Boolean { + if (v.tag == null) return false + val reaction = v.tag as Reaction + val action = event.action + if (action == MotionEvent.ACTION_DOWN) onDown(MessageId(reaction.messageId, reaction.isMms)) else if (action == MotionEvent.ACTION_CANCEL) removeLongPressCallback() else if (action == MotionEvent.ACTION_UP) onUp(reaction) + return true + } + + private fun displayReactions(threshold: Int) { + val userPublicKey = getLocalNumber(context) + val reactions = buildSortedReactionsList(records!!, userPublicKey, threshold) + binding.layoutEmojiContainer.removeAllViews() + val overflowContainer = LinearLayout(context) + overflowContainer.orientation = LinearLayout.HORIZONTAL + val innerPadding = ViewUtil.dpToPx(4) + overflowContainer.setPaddingRelative(innerPadding, innerPadding, innerPadding, innerPadding) + val pixelSize = ViewUtil.dpToPx(1) + for (reaction in reactions) { + if (binding.layoutEmojiContainer.childCount + 1 >= DEFAULT_THRESHOLD && threshold != Int.MAX_VALUE && reactions.size > threshold) { + if (overflowContainer.parent == null) { + binding.layoutEmojiContainer.addView(overflowContainer) + val overflowParams = overflowContainer.layoutParams as MarginLayoutParams + overflowParams.height = ViewUtil.dpToPx(26) + overflowParams.setMargins(pixelSize, pixelSize, pixelSize, pixelSize) + overflowContainer.layoutParams = overflowParams + overflowContainer.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background) + } + val pill = buildPill(context, this, reaction, true) + pill.setOnClickListener { v: View? -> + extended = true + displayReactions(Int.MAX_VALUE) + } + pill.findViewById<View>(R.id.reactions_pill_count).visibility = GONE + pill.findViewById<View>(R.id.reactions_pill_spacer).visibility = GONE + overflowContainer.addView(pill) + } else { + val pill = buildPill(context, this, reaction, false) + pill.tag = reaction + pill.setOnTouchListener(this) + val params = pill.layoutParams as MarginLayoutParams + params.setMargins(pixelSize, pixelSize, pixelSize, pixelSize) + pill.layoutParams = params + binding.layoutEmojiContainer.addView(pill) + } + } + val overflowChildren = overflowContainer.childCount + val negativeMargin = ViewUtil.dpToPx(-8) + for (i in 0 until overflowChildren) { + val child = overflowContainer.getChildAt(i) + val childParams = child.layoutParams as MarginLayoutParams + if (i == 0 && overflowChildren > 1 || i + 1 < overflowChildren) { + // if first and there is more than one child, or we are not the last child then set negative right margin + childParams.setMargins(0, 0, negativeMargin, 0) + child.layoutParams = childParams + } + } + if (threshold == Int.MAX_VALUE) { + binding.groupShowLess.visibility = VISIBLE + for (id in binding.groupShowLess.referencedIds) { + findViewById<View>(id).setOnClickListener { view: View? -> + extended = false + displayReactions(DEFAULT_THRESHOLD) + } + } + } else { + binding.groupShowLess.visibility = GONE + } + } + + private fun buildSortedReactionsList(records: List<ReactionRecord>, userPublicKey: String?, threshold: Int): List<Reaction> { + val counters: MutableMap<String, Reaction> = LinkedHashMap() + + records.forEach { + val baseEmoji = EmojiUtil.getCanonicalRepresentation(it.emoji) + val info = counters[baseEmoji] + + if (info == null) { + counters[baseEmoji] = Reaction(messageId, it.isMms, it.emoji, it.count, it.sortId, it.dateReceived, userPublicKey == it.author) + } + else { + info.update(it.emoji, it.count, it.dateReceived, userPublicKey == it.author) + } + } + + val reactions: List<Reaction> = ArrayList(counters.values) + Collections.sort(reactions, Collections.reverseOrder()) + + return if (reactions.size >= threshold + 2 && threshold != Int.MAX_VALUE) { + val shortened: MutableList<Reaction> = ArrayList(threshold + 2) + shortened.addAll(reactions.subList(0, threshold + 2)) + shortened + } else { + reactions + } + } + + private fun buildPill(context: Context, parent: ViewGroup, reaction: Reaction, isCompact: Boolean): View { + val root = LayoutInflater.from(context).inflate(R.layout.reactions_pill, parent, false) + val emojiView = root.findViewById<EmojiImageView>(R.id.reactions_pill_emoji) + val countView = root.findViewById<TextView>(R.id.reactions_pill_count) + val spacer = root.findViewById<View>(R.id.reactions_pill_spacer) + if (isCompact) { + root.setPaddingRelative(1, 1, 1, 1) + val layoutParams = root.layoutParams + layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT + root.layoutParams = layoutParams + } + if (reaction.emoji != null) { + emojiView.setImageEmoji(reaction.emoji) + if (reaction.count >= 1) { + countView.text = getFormattedNumber(reaction.count) + } else { + countView.visibility = GONE + spacer.visibility = GONE + } + } else { + emojiView.visibility = GONE + spacer.visibility = GONE + countView.text = context.getString(R.string.ReactionsConversationView_plus, reaction.count) + } + if (reaction.userWasSender && !isCompact) { + root.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background_selected) + countView.setTextColor(ThemeUtil.getThemedColor(context, R.attr.reactionsPillSelectedTextColor)) + } else { + if (!isCompact) { + root.background = ContextCompat.getDrawable(context, R.drawable.reaction_pill_background) + } + } + return root + } + + private fun onReactionClicked(reaction: Reaction) { + if (reaction.messageId != 0L) { + val messageId = MessageId(reaction.messageId, reaction.isMms) + delegate!!.onReactionClicked(reaction.emoji!!, messageId, reaction.userWasSender) + } + } + + private fun onDown(messageId: MessageId) { + removeLongPressCallback() + val newLongPressCallback = Runnable { + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + if (delegate != null) { + delegate!!.onReactionLongClicked(messageId) + } + } + longPressCallback = newLongPressCallback + gestureHandler.postDelayed(newLongPressCallback, longPressDurationThreshold) + onDownTimestamp = Date().time + } + + private fun removeLongPressCallback() { + if (longPressCallback != null) { + gestureHandler.removeCallbacks(longPressCallback!!) + } + } + + private fun onUp(reaction: Reaction) { + if (Date().time - onDownTimestamp < longPressDurationThreshold) { + removeLongPressCallback() + if (pressCallback != null) { + gestureHandler.removeCallbacks(pressCallback!!) + pressCallback = null + } else { + val newPressCallback = Runnable { + onReactionClicked(reaction) + pressCallback = null + } + pressCallback = newPressCallback + gestureHandler.postDelayed(newPressCallback, maxDoubleTapInterval) + } + } + } + + internal class Reaction( + internal val messageId: Long, + internal val isMms: Boolean, + internal var emoji: String?, + internal var count: Long, + internal val sortIndex: Long, + internal var lastSeen: Long, + internal var userWasSender: Boolean + ) : Comparable<Reaction?> { + fun update(emoji: String, count: Long, lastSeen: Long, userWasSender: Boolean) { + if (!this.userWasSender) { + if (userWasSender || lastSeen > this.lastSeen) { + this.emoji = emoji + } + } + this.count = this.count + count + this.lastSeen = Math.max(this.lastSeen, lastSeen) + this.userWasSender = this.userWasSender || userWasSender + } + + fun merge(other: Reaction): Reaction { + count = count + other.count + lastSeen = Math.max(lastSeen, other.lastSeen) + userWasSender = userWasSender || other.userWasSender + return this + } + + override fun compareTo(other: Reaction?): Int { + if (other == null) { return -1 } + + if (this.count == other.count) { + return this.sortIndex.compareTo(other.sortIndex) + } + + return this.count.compareTo(other.count) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt index cb6bb536ff..9677223894 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt @@ -4,11 +4,9 @@ import android.content.Context import android.graphics.Canvas import android.graphics.Rect import android.util.AttributeSet -import android.view.LayoutInflater import android.view.MotionEvent import android.widget.LinearLayout import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.res.ResourcesCompat import androidx.core.view.isVisible import network.loki.messenger.R import network.loki.messenger.databinding.ViewLinkPreviewBinding @@ -19,21 +17,16 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MessageBubbleUtiliti import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.ImageSlide -import org.thoughtcrime.securesms.util.UiModeUtilities class LinkPreviewView : LinearLayout { - private lateinit var binding: ViewLinkPreviewBinding + private val binding: ViewLinkPreviewBinding by lazy { ViewLinkPreviewBinding.bind(this) } private val cornerMask by lazy { CornerMask(this) } private var url: String? = null // 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 fun initialize() { - binding = ViewLinkPreviewBinding.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) // endregion // region Updating @@ -48,8 +41,8 @@ class LinkPreviewView : LinearLayout { // Thumbnail if (linkPreview.getThumbnail().isPresent) { // This internally fetches the thumbnail - binding.thumbnailImageView.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message) - binding.thumbnailImageView.loadIndicator.isVisible = false + binding.thumbnailImageView.root.setImageResource(glide, ImageSlide(context, linkPreview.getThumbnail().get()), isPreview = false, message) + binding.thumbnailImageView.root.loadIndicator.isVisible = false } // Title binding.titleTextView.text = linkPreview.title @@ -64,8 +57,12 @@ class LinkPreviewView : LinearLayout { val cornerRadii = MessageBubbleUtilities.calculateRadii(context, isStartOfMessageCluster, isEndOfMessageCluster, message.isOutgoing) cornerMask.setTopLeftRadius(cornerRadii[0]) cornerMask.setTopRightRadius(cornerRadii[1]) - cornerMask.setBottomRightRadius(cornerRadii[2]) - cornerMask.setBottomLeftRadius(cornerRadii[3]) + + // Only round the bottom corners if there is no body text + if (message.body.isEmpty()) { + cornerMask.setBottomRightRadius(cornerRadii[2]) + cornerMask.setBottomLeftRadius(cornerRadii[3]) + } } override fun dispatchDraw(canvas: Canvas) { @@ -80,7 +77,7 @@ class LinkPreviewView : LinearLayout { val rawYInt = event.rawY.toInt() val hitRect = Rect(rawXInt, rawYInt, rawXInt, rawYInt) val previewRect = Rect() - binding.mainLinkPreviewParent.getGlobalVisibleRect(previewRect) + binding.mainLinkPreviewContainer.getGlobalVisibleRect(previewRect) if (previewRect.contains(hitRect)) { openURL() return 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 91ab4c106d..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) @@ -93,7 +93,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? val backgroundColor = context.getAccentColor() binding.quoteViewAttachmentPreviewContainer.backgroundTintList = ColorStateList.valueOf(backgroundColor) binding.quoteViewAttachmentPreviewImageView.isVisible = false - binding.quoteViewAttachmentThumbnailImageView.isVisible = false + binding.quoteViewAttachmentThumbnailImageView.root.isVisible = false when { attachments.audioSlide != null -> { binding.quoteViewAttachmentPreviewImageView.setImageResource(R.drawable.ic_microphone) @@ -108,9 +108,9 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet? attachments.thumbnailSlide != null -> { val slide = attachments.thumbnailSlide!! // This internally fetches the thumbnail - binding.quoteViewAttachmentThumbnailImageView.radius = toPx(4, resources) - binding.quoteViewAttachmentThumbnailImageView.setImageResource(glide, slide, false, false) - binding.quoteViewAttachmentThumbnailImageView.isVisible = true + binding.quoteViewAttachmentThumbnailImageView.root.radius = toPx(4, resources) + binding.quoteViewAttachmentThumbnailImageView.root.setImageResource(glide, slide, false, null) + binding.quoteViewAttachmentThumbnailImageView.root.isVisible = true binding.quoteViewBodyTextView.text = if (MediaUtil.isVideo(slide.asAttachment())) resources.getString(R.string.Slide_video) else resources.getString(R.string.Slide_image) } } 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 88045f8e47..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 @@ -3,35 +3,31 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.graphics.Color import android.graphics.Rect -import android.graphics.drawable.Drawable import android.text.Spannable import android.text.style.BackgroundColorSpan import android.text.style.ForegroundColorSpan import android.text.style.URLSpan import android.text.util.Linkify import android.util.AttributeSet -import android.view.LayoutInflater import android.view.MotionEvent import android.view.View -import android.widget.LinearLayout import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.BlendModeColorFilterCompat -import androidx.core.graphics.BlendModeCompat +import androidx.core.graphics.ColorUtils import androidx.core.text.getSpans import androidx.core.text.toSpannable +import androidx.core.view.children import androidx.core.view.isVisible import network.loki.messenger.R import network.loki.messenger.databinding.ViewVisibleMessageContentBinding import okhttp3.HttpUrl import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.jobs.AttachmentDownloadJob -import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment +import org.session.libsession.utilities.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 @@ -41,67 +37,77 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.getInt import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.SmsMessageRecord +import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.getAccentColor import java.util.Locale import kotlin.math.roundToInt -class VisibleMessageContentView : LinearLayout { - private lateinit var binding: ViewVisibleMessageContentBinding - var onContentClick: MutableList<((event: MotionEvent) -> Unit)> = mutableListOf() +class VisibleMessageContentView : ConstraintLayout { + private val binding: ViewVisibleMessageContentBinding by lazy { ViewVisibleMessageContentBinding.bind(this) } var onContentDoubleTap: (() -> Unit)? = null var delegate: VisibleMessageViewDelegate? = null var indexInAdapter: Int = -1 // 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 fun initialize() { - binding = ViewVisibleMessageContentBinding.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) // endregion // region Updating - fun bind(message: MessageRecord, isStartOfMessageCluster: Boolean, isEndOfMessageCluster: Boolean, - glide: GlideRequests, thread: Recipient, searchQuery: String?, contactIsTrusted: Boolean) { + fun bind( + message: MessageRecord, + isStartOfMessageCluster: Boolean = true, + isEndOfMessageCluster: Boolean = true, + glide: GlideRequests = GlideApp.with(this), + thread: Recipient, + searchQuery: String? = null, + contactIsTrusted: Boolean = true, + onAttachmentNeedsDownload: (Long, Long) -> Unit, + suppressThumbnails: Boolean = false + ) { // Background - val background = getBackground(message.isOutgoing) val color = if (message.isOutgoing) context.getAccentColor() else context.getColorFromAttr(R.attr.message_received_background_color) - val filter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN) - background.colorFilter = filter - binding.contentParent.background = background + binding.contentParent.mainColor = color + binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius) val onlyBodyMessage = message is SmsMessageRecord val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null // reset visibilities / containers onContentClick.clear() - binding.albumThumbnailView.clearViews() + binding.albumThumbnailView.root.clearViews() onContentDoubleTap = null if (message.isDeleted) { binding.deletedMessageView.root.isVisible = true binding.deletedMessageView.root.bind(message, getTextColor(context, message)) + binding.bodyTextView.isVisible = false + binding.quoteView.root.isVisible = false + binding.linkPreviewView.root.isVisible = false + binding.untrustedView.root.isVisible = false + binding.voiceMessageView.root.isVisible = false + binding.documentView.root.isVisible = false + binding.albumThumbnailView.root.isVisible = false + binding.openGroupInvitationView.root.isVisible = false return } else { binding.deletedMessageView.root.isVisible = false } - // clear the + + // Note: Need to clear the body to prevent the message bubble getting incorrectly + // sized based on text content from a recycled view binding.bodyTextView.text = null - - binding.quoteView.root.isVisible = message is MmsMessageRecord && message.quote != null - - binding.linkPreviewView.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty() - + binding.linkPreviewView.root.isVisible = message is MmsMessageRecord && message.linkPreviews.isNotEmpty() binding.untrustedView.root.isVisible = !contactIsTrusted && message is MmsMessageRecord && message.quote == null && message.linkPreviews.isEmpty() binding.voiceMessageView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.audioSlide != null binding.documentView.root.isVisible = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.documentSlide != null - binding.albumThumbnailView.isVisible = mediaThumbnailMessage + binding.albumThumbnailView.root.isVisible = mediaThumbnailMessage binding.openGroupInvitationView.root.isVisible = message.isOpenGroupInvitation var hideBody = false @@ -124,7 +130,6 @@ class VisibleMessageContentView : LinearLayout { delegate?.scrollToMessageIfPossible(quote.id) } } - val hasMedia = message.slideDeck.asAttachments().isNotEmpty() } if (message is MmsMessageRecord) { @@ -133,8 +138,7 @@ class VisibleMessageContentView : LinearLayout { val attachmentId = dbAttachment.attachmentId.rowId if (attach.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { - // start download - JobQueue.shared.add(AttachmentDownloadJob(attachmentId, dbAttachment.mmsId)) + onAttachmentNeedsDownload(attachmentId, dbAttachment.mmsId) } } message.linkPreviews.forEach { preview -> @@ -142,16 +146,18 @@ class VisibleMessageContentView : LinearLayout { val attachmentId = previewThumbnail.attachmentId.rowId if (previewThumbnail.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING && MessagingModuleConfiguration.shared.storage.getAttachmentUploadJob(attachmentId) == null) { - JobQueue.shared.add(AttachmentDownloadJob(attachmentId, previewThumbnail.mmsId)) + onAttachmentNeedsDownload(attachmentId, previewThumbnail.mmsId) } } } when { message is MmsMessageRecord && message.linkPreviews.isNotEmpty() -> { - binding.linkPreviewView.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) - onContentClick.add { event -> binding.linkPreviewView.calculateHit(event) } - // Body text view is inside the link preview for layout convenience + binding.linkPreviewView.root.bind(message, glide, isStartOfMessageCluster, isEndOfMessageCluster) + onContentClick.add { event -> binding.linkPreviewView.root.calculateHit(event) } + + // When in a link preview ensure the bodyTextView can expand to the full width + binding.bodyTextView.maxWidth = binding.linkPreviewView.root.layoutParams.width } message is MmsMessageRecord && message.slideDeck.audioSlide != null -> { hideBody = true @@ -180,28 +186,28 @@ class VisibleMessageContentView : LinearLayout { onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } } } - message is MmsMessageRecord && message.slideDeck.asAttachments().isNotEmpty() -> { + message is MmsMessageRecord && !suppressThumbnails && message.slideDeck.asAttachments().isNotEmpty() -> { /* * Images / Video attachment */ if (contactIsTrusted || message.isOutgoing) { // isStart and isEnd of cluster needed for calculating the mask for full bubble image groups // bind after add view because views are inflated and calculated during bind - binding.albumThumbnailView.bind( + binding.albumThumbnailView.root.bind( glideRequests = glide, message = message, isStart = isStartOfMessageCluster, isEnd = isEndOfMessageCluster ) - val layoutParams = binding.albumThumbnailView.layoutParams as ConstraintLayout.LayoutParams - layoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f - binding.albumThumbnailView.layoutParams = layoutParams + binding.albumThumbnailView.root.modifyLayoutParams<ConstraintLayout.LayoutParams> { + horizontalBias = if (message.isOutgoing) 1f else 0f + } onContentClick.add { event -> - binding.albumThumbnailView.calculateHitObject(event, message, thread) + binding.albumThumbnailView.root.calculateHitObject(event, message, thread, onAttachmentNeedsDownload) } } else { hideBody = true - binding.albumThumbnailView.clearViews() + binding.albumThumbnailView.root.clearViews() binding.untrustedView.root.bind(UntrustedAttachmentView.AttachmentType.MEDIA, VisibleMessageContentView.getTextColor(context,message)) onContentClick.add { binding.untrustedView.root.showTrustDialog(message.individualRecipient) } } @@ -214,6 +220,7 @@ class VisibleMessageContentView : LinearLayout { } binding.bodyTextView.isVisible = message.body.isNotEmpty() && !hideBody + binding.contentParent.apply { isVisible = children.any { it.isVisible } } if (message.body.isNotEmpty() && !hideBody) { val color = getTextColor(context, message) @@ -227,18 +234,19 @@ class VisibleMessageContentView : LinearLayout { } } } - 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() + + fun onContentClick(event: MotionEvent) { + onContentClick.forEach { clickHandler -> clickHandler.invoke(event) } } private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean = - listOf<View>(albumThumbnailView, linkPreviewView, voiceMessageView.root, quoteView.root).none { it.isVisible } - - private fun getBackground(isOutgoing: Boolean): Drawable { - val backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone - return ResourcesCompat.getDrawable(resources, backgroundID, context.theme)!! - } + listOf<View>(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible } fun recycle() { arrayOf( @@ -248,8 +256,8 @@ class VisibleMessageContentView : LinearLayout { binding.openGroupInvitationView.root, binding.documentView.root, binding.quoteView.root, - binding.linkPreviewView, - binding.albumThumbnailView, + binding.linkPreviewView.root, + binding.albumThumbnailView.root, binding.bodyTextView ).forEach { view: View -> view.isVisible = false } } @@ -257,6 +265,15 @@ class VisibleMessageContentView : LinearLayout { fun playVoiceMessage() { binding.voiceMessageView.root.togglePlayback() } + + fun playHighlight() { + // Show the highlight colour immediately then slowly fade out + val targetColor = if (ThemeUtil.isDarkTheme(context)) context.getAccentColor() else resources.getColor(R.color.black, context.theme) + val clearTargetColor = ColorUtils.setAlphaComponent(targetColor, 0) + binding.contentParent.numShadowRenders = if (ThemeUtil.isDarkTheme(context)) 3 else 1 + binding.contentParent.sessionShadowColor = targetColor + GlowViewUtilities.animateShadowColorChange(binding.contentParent, targetColor, clearTargetColor, 1600) + } // endregion // region Convenience @@ -290,16 +307,9 @@ class VisibleMessageContentView : LinearLayout { } @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 0b4c1455fc..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,38 +1,46 @@ package org.thoughtcrime.securesms.conversation.v2.messages +import android.annotation.SuppressLint import android.content.Context import android.content.Intent -import android.content.res.Resources import android.graphics.Canvas import android.graphics.Rect import android.graphics.drawable.ColorDrawable import android.os.Handler import android.os.Looper import android.util.AttributeSet +import android.view.Gravity import android.view.HapticFeedbackConstants +import android.view.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 +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes 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.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 @@ -42,6 +50,7 @@ import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.UserDetailsBottomSheet +import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.disableClipping @@ -55,18 +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 screenWidth = Resources.getSystem().displayMetrics.widthPixels private val swipeToReplyIcon = ContextCompat.getDrawable(context, R.drawable.ic_baseline_reply_24)!!.mutate() private val swipeToReplyIconRect = Rect() private var dx = 0.0f @@ -85,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 } + val messageContentView: VisibleMessageContentView get() = binding.messageContentView.root companion object { const val swipeToReplyThreshold = 64.0f // dp @@ -99,39 +119,44 @@ 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() + binding.mainContainer.disableClipping() binding.messageInnerContainer.disableClipping() - binding.messageContentView.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 fun bind( message: MessageRecord, - previous: MessageRecord?, - next: MessageRecord?, - glide: GlideRequests, - searchQuery: String?, - contact: Contact?, + previous: MessageRecord? = null, + next: MessageRecord? = null, + glide: GlideRequests = GlideApp.with(this), + searchQuery: String? = null, + contact: Contact? = null, senderSessionID: String, - delegate: VisibleMessageViewDelegate?, + lastSeen: Long, + delegate: VisibleMessageViewDelegate? = null, + onAttachmentNeedsDownload: (Long, Long) -> Unit, + 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.root.visibility = when { + binding.profilePictureView.visibility = when { thread.isGroupRecipient && !message.isOutgoing && isEndOfMessageCluster -> View.VISIBLE thread.isGroupRecipient -> View.INVISIBLE else -> View.GONE @@ -140,24 +165,25 @@ class VisibleMessageView : LinearLayout { val bottomMargin = if (isEndOfMessageCluster) resources.getDimensionPixelSize(R.dimen.small_spacing) else ViewUtil.dpToPx(context,2) - if (binding.profilePictureView.root.visibility == View.GONE) { + if (binding.profilePictureView.visibility == View.GONE) { val expirationParams = binding.messageInnerContainer.layoutParams as MarginLayoutParams expirationParams.bottomMargin = bottomMargin binding.messageInnerContainer.layoutParams = expirationParams } else { - val avatarLayoutParams = binding.profilePictureView.root.layoutParams as MarginLayoutParams + val avatarLayoutParams = binding.profilePictureView.layoutParams as MarginLayoutParams avatarLayoutParams.bottomMargin = bottomMargin - binding.profilePictureView.root.layoutParams = avatarLayoutParams + binding.profilePictureView.layoutParams = avatarLayoutParams } if (isGroupThread && !message.isOutgoing) { if (isEndOfMessageCluster) { - binding.profilePictureView.root.publicKey = senderSessionID - binding.profilePictureView.root.glide = glide - binding.profilePictureView.root.update(message.individualRecipient) - binding.profilePictureView.root.setOnClickListener { - if (thread.isOpenGroupRecipient) { - if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED) { + binding.profilePictureView.publicKey = senderSessionID + binding.profilePictureView.update(message.individualRecipient) + binding.profilePictureView.setOnClickListener { + if (thread.isCommunityRecipient) { + val openGroup = lokiThreadDb.getOpenGroupChat(threadID) + if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) { + // TODO: support v2 soon val intent = Intent(context, ConversationActivityV2::class.java) intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID) intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderSessionID)) @@ -167,11 +193,11 @@ class VisibleMessageView : LinearLayout { maybeShowUserDetails(senderSessionID, threadID) } } - if (thread.isOpenGroupRecipient) { + if (thread.isCommunityRecipient) { val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return var standardPublicKey = "" var blindedPublicKey: String? = null - if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED) { + if (IdPrefix.fromValue(senderSessionID)?.isBlinded() == true) { blindedPublicKey = senderSessionID } else { standardPublicKey = senderSessionID @@ -183,144 +209,232 @@ 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 + 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 - val (iconID, iconColor) = getMessageStatusImage(message) - if (iconID != null) { - val drawable = ContextCompat.getDrawable(context, iconID)?.mutate() - if (iconColor != null) { - drawable?.setTint(iconColor) - } - binding.messageStatusImageView.setImageDrawable(drawable) - } - if (message.isOutgoing) { - val lastMessageID = mmsSmsDb.getLastMessageID(message.threadId) - binding.messageStatusImageView.isVisible = - !message.isSent || message.id == lastMessageID - } else { - binding.messageStatusImageView.isVisible = false - } - // Expiration timer - updateExpirationTimer(message) + + // Update message status indicator + showStatusMessage(message) + // Emoji Reactions - val emojiLayoutParams = binding.emojiReactionsView.layoutParams as ConstraintLayout.LayoutParams - emojiLayoutParams.horizontalBias = if (message.isOutgoing) 1f else 0f - binding.emojiReactionsView.layoutParams = emojiLayoutParams - val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) } - if (message.reactions.isNotEmpty() && - (capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) - ) { - binding.emojiReactionsView.setReactions(message.id, message.reactions, message.isOutgoing, delegate) - binding.emojiReactionsView.isVisible = true - } else { - binding.emojiReactionsView.isVisible = false + if (message.reactions.isNotEmpty()) { + val capabilities = lokiThreadDb.getOpenGroupChat(threadID)?.server?.let { lokiApiDb.getServerCapabilities(it) } + if (capabilities.isNullOrEmpty() || capabilities.contains(OpenGroupApi.Capability.REACTIONS.name.lowercase())) { + 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 if (emojiReactionsBinding.isInitialized()) { + emojiReactionsBinding.value.root.isVisible = false } // Populate content view - binding.messageContentView.indexInAdapter = indexInAdapter - binding.messageContentView.bind( + binding.messageContentView.root.indexInAdapter = indexInAdapter + binding.messageContentView.root.bind( message, isStartOfMessageCluster, isEndOfMessageCluster, glide, thread, searchQuery, - message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false) + message.isOutgoing || isGroupThread || (contact?.isTrusted ?: false), + onAttachmentNeedsDownload ) - binding.messageContentView.delegate = delegate - onDoubleTap = { binding.messageContentView.onContentDoubleTap?.invoke() } + binding.messageContentView.root.delegate = delegate + 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 getMessageStatusImage(message: MessageRecord): Pair<Int?,Int?> { - return when { - !message.isOutgoing -> null to null - message.isFailed -> R.drawable.ic_error to resources.getColor(R.color.destructive, context.theme) - message.isPending -> R.drawable.ic_circle_dot_dot_dot to null - message.isRead -> R.drawable.ic_filled_circle_check to null - else -> R.drawable.ic_circle_check to null + 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?) + + private fun getMessageStatusInfo(message: MessageRecord): MessageStatusInfo = when { + message.isFailed -> + MessageStatusInfo(R.drawable.ic_delivery_status_failed, + resources.getColor(R.color.destructive, context.theme), + R.string.delivery_status_failed + ) + message.isSyncFailed -> + MessageStatusInfo( + R.drawable.ic_delivery_status_failed, + context.getColor(R.color.accent_orange), + 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 + ) + message.isSyncing || message.isResyncing -> + MessageStatusInfo( + R.drawable.ic_delivery_status_sending, + 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.isIncoming -> + MessageStatusInfo( + R.drawable.ic_delivery_status_read, + context.getColorFromAttr(R.attr.message_status_color), + R.string.delivery_status_read + ) + message.isSent -> + MessageStatusInfo( + R.drawable.ic_delivery_status_sent, + context.getColorFromAttr(R.attr.message_status_color), + 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 content = binding.messageContentView - val expiration = binding.expirationTimerView - val spacing = binding.messageContentSpacing - container.removeAllViewsInLayout() - container.addView(if (message.isOutgoing) expiration else content) - container.addView(if (message.isOutgoing) content else expiration) - container.addView(spacing, if (message.isOutgoing) 0 else 2) - 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 <= System.currentTimeMillis()) { - 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) { val spacing = context.resources.getDimensionPixelSize(R.dimen.small_spacing) val iconSize = toPx(24, context.resources) - val left = binding.messageInnerContainer.left + binding.messageContentView.right + spacing - val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.root.marginBottom - (iconSize / 2) + val left = binding.messageInnerContainer.left + binding.messageContentView.root.right + spacing + val top = height - (binding.messageInnerContainer.height / 2) - binding.profilePictureView.marginBottom - (iconSize / 2) val right = left + iconSize val bottom = top + iconSize swipeToReplyIconRect.left = left @@ -340,12 +454,17 @@ class VisibleMessageView : LinearLayout { } fun recycle() { - binding.profilePictureView.root.recycle() - binding.messageContentView.recycle() + binding.profilePictureView.recycle() + binding.messageContentView.root.recycle() + } + + fun playHighlight() { + binding.messageContentView.root.playHighlight() } // endregion // region Interaction + @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { if (onPress == null || onSwipeToReply == null || onLongPress == null) { return false } when (event.action) { @@ -373,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 @@ -437,7 +557,7 @@ class VisibleMessageView : LinearLayout { } fun onContentClick(event: MotionEvent) { - binding.messageContentView.onContentClick.iterator().forEach { clickHandler -> clickHandler.invoke(event) } + binding.messageContentView.root.onContentClick(event) } private fun onPress(event: MotionEvent) { @@ -446,18 +566,17 @@ 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() { - binding.messageContentView.playVoiceMessage() + binding.messageContentView.root.playVoiceMessage() } // endregion } 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 451368e1cb..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 -> @@ -78,7 +78,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener { binding.voiceMessageViewDurationTextView.visibility = View.VISIBLE binding.voiceMessageViewDurationTextView.text = String.format("%01d:%02d", TimeUnit.MILLISECONDS.toMinutes(audioExtras.durationMs), - TimeUnit.MILLISECONDS.toSeconds(audioExtras.durationMs)) + TimeUnit.MILLISECONDS.toSeconds(audioExtras.durationMs) % 60) } } } @@ -92,7 +92,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener { if (progress == 1.0) { togglePlayback() handleProgressChanged(0.0) - delegate?.playVoiceMessageAtIndexIfPossible(indexInAdapter - 1) + delegate?.playVoiceMessageAtIndexIfPossible(indexInAdapter + 1) } else { handleProgressChanged(progress) } @@ -102,7 +102,7 @@ class VoiceMessageView : RelativeLayout, AudioSlidePlayer.Listener { this.progress = progress binding.voiceMessageViewDurationTextView.text = String.format("%01d:%02d", TimeUnit.MILLISECONDS.toMinutes(duration - (progress * duration.toDouble()).roundToLong()), - TimeUnit.MILLISECONDS.toSeconds(duration - (progress * duration.toDouble()).roundToLong())) + TimeUnit.MILLISECONDS.toSeconds(duration - (progress * duration.toDouble()).roundToLong()) % 60) val layoutParams = binding.progressView.layoutParams as RelativeLayout.LayoutParams layoutParams.width = (width.toFloat() * progress.toFloat()).roundToInt() binding.progressView.layoutParams = layoutParams 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 dd90b699e3..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 @@ -25,6 +25,7 @@ import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.os.AsyncTask; +import android.os.Build; import android.provider.OpenableColumns; import android.text.TextUtils; import android.util.Pair; @@ -240,16 +241,35 @@ 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) { - Permissions.with(activity) - .request(Manifest.permission.READ_EXTERNAL_STORAGE) - .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) - .withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24) - .onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode)) - .execute(); + Permissions.PermissionsBuilder builder = Permissions.with(activity); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + builder = builder.request(Manifest.permission.READ_MEDIA_VIDEO) + .request(Manifest.permission.READ_MEDIA_IMAGES); + } else { + builder = builder.request(Manifest.permission.READ_EXTERNAL_STORAGE); + } + builder.withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) + .withRationaleDialog(activity.getString(R.string.ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage), R.drawable.ic_baseline_photo_library_24) + .onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.buildGalleryIntent(activity, recipient, body), requestCode)) + .execute(); } public static void selectAudio(Activity activity, int requestCode) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/BaseDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/BaseDialog.kt deleted file mode 100644 index e1456a7f94..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/BaseDialog.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.utilities - -import android.app.Dialog -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.os.Bundle -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.DialogFragment -import org.thoughtcrime.securesms.util.UiModeUtilities - -open class BaseDialog : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = AlertDialog.Builder(requireContext()) - setContentView(builder) - val result = builder.create() - result.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - val isLightMode = UiModeUtilities.isDayUiMode(requireContext()) - result.window?.setDimAmount(if (isLightMode) 0.1f else 0.75f) - return result - } - - open fun setContentView(builder: AlertDialog.Builder) { - // To be overridden by subclasses - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/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/NotificationUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt index dbbcfb51ef..c0ce83f631 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/NotificationUtils.kt @@ -1,21 +1,18 @@ package org.thoughtcrime.securesms.conversation.v2.utilities import android.content.Context -import androidx.appcompat.app.AlertDialog import network.loki.messenger.R import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.showSessionDialog object NotificationUtils { fun showNotifyDialog(context: Context, thread: Recipient, notifyTypeHandler: (Int)->Unit) { - val notifyTypes = context.resources.getStringArray(R.array.notify_types) - val currentSelected = thread.notifyType - - AlertDialog.Builder(context) - .setSingleChoiceItems(notifyTypes,currentSelected) { d, newSelection -> - notifyTypeHandler(newSelection) - d.dismiss() - } - .setTitle(R.string.RecipientPreferenceActivity_notification_settings) - .show() + context.showSessionDialog { + title(R.string.RecipientPreferenceActivity_notification_settings) + singleChoiceItems( + context.resources.getStringArray(R.array.notify_types), + thread.notifyType + ) { notifyTypeHandler(it) } + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt index 80f4cc0bf8..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 @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2.utilities import android.content.Context import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.visible.LinkPreview import org.session.libsession.messaging.messages.visible.OpenGroupInvitation import org.session.libsession.messaging.messages.visible.Quote @@ -15,7 +16,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord object ResendMessageUtilities { - fun resend(context: Context, messageRecord: MessageRecord, userBlindedKey: String?) { + fun resend(context: Context, messageRecord: MessageRecord, userBlindedKey: String?, isResync: Boolean = false) { val recipient: Recipient = messageRecord.recipient val message = VisibleMessage() message.id = messageRecord.getId() @@ -39,24 +40,27 @@ 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() if (sentTimestamp != null && sender != null) { - MessagingModuleConfiguration.shared.storage.markAsSending(sentTimestamp, sender) + if (isResync) { + MessagingModuleConfiguration.shared.storage.markAsResyncing(sentTimestamp, sender) + MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = true) + } else { + MessagingModuleConfiguration.shared.storage.markAsSending(sentTimestamp, sender) + MessageSender.send(message, recipient.address) + } } - MessageSender.send(message, recipient.address) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt index 800ace54c3..7a47b92756 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/TextUtilities.kt @@ -38,13 +38,12 @@ object TextUtilities { fun TextView.getIntersectedModalSpans(hitRect: Rect): List<ModalURLSpan> { val textLayout = layout ?: return emptyList() val lineRect = Rect() - val bodyTextRect = Rect() - getGlobalVisibleRect(bodyTextRect) + val offset = intArrayOf(0, 0).also { getLocationOnScreen(it) } val textSpannable = text.toSpannable() return (0 until textLayout.lineCount).flatMap { line -> textLayout.getLineBounds(line, lineRect) - lineRect.offset(bodyTextRect.left + totalPaddingLeft, bodyTextRect.top + totalPaddingTop) - if ((Rect(lineRect)).contains(hitRect)) { + lineRect.offset(offset[0] + totalPaddingLeft, offset[1] + totalPaddingTop) + if (lineRect.contains(hitRect)) { // calculate the url span intersected with (if any) val off = textLayout.getOffsetForHorizontal(line, hitRect.left.toFloat()) // left and right will be the same textSpannable.getSpans<ModalURLSpan>(off, off).toList() 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/conversation/v2/utilities/ThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java deleted file mode 100644 index 912253ecd8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.java +++ /dev/null @@ -1,425 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.utilities; - -import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade; - -import android.content.Context; -import android.content.res.TypedArray; -import android.net.Uri; -import android.util.AttributeSet; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; -import android.widget.ImageView; - -import androidx.annotation.NonNull; -import androidx.annotation.UiThread; - -import com.bumptech.glide.RequestBuilder; -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.load.resource.bitmap.BitmapTransformation; -import com.bumptech.glide.load.resource.bitmap.CenterCrop; -import com.bumptech.glide.load.resource.bitmap.FitCenter; -import com.bumptech.glide.load.resource.bitmap.RoundedCorners; -import com.bumptech.glide.request.RequestOptions; - -import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.ViewUtil; -import org.session.libsignal.utilities.ListenableFuture; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.SettableFuture; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.components.GlideBitmapListeningTarget; -import org.thoughtcrime.securesms.components.GlideDrawableListeningTarget; -import org.thoughtcrime.securesms.components.TransferControlView; -import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; -import org.thoughtcrime.securesms.mms.GlideRequest; -import org.thoughtcrime.securesms.mms.GlideRequests; -import org.thoughtcrime.securesms.mms.Slide; -import org.thoughtcrime.securesms.mms.SlideClickListener; -import org.thoughtcrime.securesms.mms.SlidesClickedListener; - -import java.util.Collections; -import java.util.Locale; - -import network.loki.messenger.R; - -public class ThumbnailView extends FrameLayout { - - private static final String TAG = ThumbnailView.class.getSimpleName(); - private static final int WIDTH = 0; - private static final int HEIGHT = 1; - private static final int MIN_WIDTH = 0; - private static final int MAX_WIDTH = 1; - private static final int MIN_HEIGHT = 2; - private static final int MAX_HEIGHT = 3; - - private ImageView image; - private View playOverlay; - private View loadIndicator; - private OnClickListener parentClickListener; - - private final int[] dimens = new int[2]; - private final int[] bounds = new int[4]; - private final int[] measureDimens = new int[2]; - - private Optional<TransferControlView> transferControls = Optional.absent(); - private SlideClickListener thumbnailClickListener = null; - private SlidesClickedListener downloadClickListener = null; - private Slide slide = null; - - public int radius; - - public ThumbnailView(Context context) { - this(context, null); - } - - public ThumbnailView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public ThumbnailView(final Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - - inflate(context, R.layout.thumbnail_view, this); - - this.image = findViewById(R.id.thumbnail_image); - this.playOverlay = findViewById(R.id.play_overlay); - this.loadIndicator = findViewById(R.id.thumbnail_load_indicator); - super.setOnClickListener(new ThumbnailClickDispatcher()); - - if (attrs != null) { - TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0); - bounds[MIN_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minWidth, 0); - bounds[MAX_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0); - bounds[MIN_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0); - bounds[MAX_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0); - radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, 0); - typedArray.recycle(); - } else { - radius = 0; - } - } - - @Override - protected void onMeasure(int originalWidthMeasureSpec, int originalHeightMeasureSpec) { - fillTargetDimensions(measureDimens, dimens, bounds); - if (measureDimens[WIDTH] == 0 && measureDimens[HEIGHT] == 0) { - super.onMeasure(originalWidthMeasureSpec, originalHeightMeasureSpec); - return; - } - - int finalWidth = measureDimens[WIDTH] + getPaddingLeft() + getPaddingRight(); - int finalHeight = measureDimens[HEIGHT] + getPaddingTop() + getPaddingBottom(); - - super.onMeasure(MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY)); - } - - @SuppressWarnings("SuspiciousNameCombination") - private void fillTargetDimensions(int[] targetDimens, int[] dimens, int[] bounds) { - int dimensFilledCount = getNonZeroCount(dimens); - int boundsFilledCount = getNonZeroCount(bounds); - - if (dimensFilledCount == 0 || boundsFilledCount == 0) { - targetDimens[WIDTH] = 0; - targetDimens[HEIGHT] = 0; - return; - } - - double naturalWidth = dimens[WIDTH]; - double naturalHeight = dimens[HEIGHT]; - - int minWidth = bounds[MIN_WIDTH]; - int maxWidth = bounds[MAX_WIDTH]; - int minHeight = bounds[MIN_HEIGHT]; - int maxHeight = bounds[MAX_HEIGHT]; - - if (dimensFilledCount > 0 && dimensFilledCount < dimens.length) { - throw new IllegalStateException(String.format(Locale.ENGLISH, "Width or height has been specified, but not both. Dimens: %f x %f", - naturalWidth, naturalHeight)); - } - if (boundsFilledCount > 0 && boundsFilledCount < bounds.length) { - throw new IllegalStateException(String.format(Locale.ENGLISH, "One or more min/max dimensions have been specified, but not all. Bounds: [%d, %d, %d, %d]", - minWidth, maxWidth, minHeight, maxHeight)); - } - - double measuredWidth = naturalWidth; - double measuredHeight = naturalHeight; - - boolean widthInBounds = measuredWidth >= minWidth && measuredWidth <= maxWidth; - boolean heightInBounds = measuredHeight >= minHeight && measuredHeight <= maxHeight; - - if (!widthInBounds || !heightInBounds) { - double minWidthRatio = naturalWidth / minWidth; - double maxWidthRatio = naturalWidth / maxWidth; - double minHeightRatio = naturalHeight / minHeight; - double maxHeightRatio = naturalHeight / maxHeight; - - if (maxWidthRatio > 1 || maxHeightRatio > 1) { - if (maxWidthRatio >= maxHeightRatio) { - measuredWidth /= maxWidthRatio; - measuredHeight /= maxWidthRatio; - } else { - measuredWidth /= maxHeightRatio; - measuredHeight /= maxHeightRatio; - } - - measuredWidth = Math.max(measuredWidth, minWidth); - measuredHeight = Math.max(measuredHeight, minHeight); - - } else if (minWidthRatio < 1 || minHeightRatio < 1) { - if (minWidthRatio <= minHeightRatio) { - measuredWidth /= minWidthRatio; - measuredHeight /= minWidthRatio; - } else { - measuredWidth /= minHeightRatio; - measuredHeight /= minHeightRatio; - } - - measuredWidth = Math.min(measuredWidth, maxWidth); - measuredHeight = Math.min(measuredHeight, maxHeight); - } - } - - targetDimens[WIDTH] = (int) measuredWidth; - targetDimens[HEIGHT] = (int) measuredHeight; - } - - private int getNonZeroCount(int[] vals) { - int count = 0; - for (int val : vals) { - if (val > 0) { - count++; - } - } - return count; - } - - @Override - public void setOnClickListener(OnClickListener l) { - parentClickListener = l; - } - - @Override - public void setFocusable(boolean focusable) { - super.setFocusable(focusable); - if (transferControls.isPresent()) transferControls.get().setFocusable(focusable); - } - - @Override - public void setClickable(boolean clickable) { - super.setClickable(clickable); - if (transferControls.isPresent()) transferControls.get().setClickable(clickable); - } - - private TransferControlView getTransferControls() { - if (!transferControls.isPresent()) { - transferControls = Optional.of(ViewUtil.inflateStub(this, R.id.transfer_controls_stub)); - } - return transferControls.get(); - } - - public void setBounds(int minWidth, int maxWidth, int minHeight, int maxHeight) { - bounds[MIN_WIDTH] = minWidth; - bounds[MAX_WIDTH] = maxWidth; - bounds[MIN_HEIGHT] = minHeight; - bounds[MAX_HEIGHT] = maxHeight; - - forceLayout(); - } - - @UiThread - public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide, - boolean showControls, boolean isPreview) - { - return setImageResource(glideRequests, slide, showControls, isPreview, 0, 0); - } - - @UiThread - public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Slide slide, - boolean showControls, boolean isPreview, - int naturalWidth, int naturalHeight) - { - if (showControls) { - getTransferControls().setSlide(slide); - getTransferControls().setDownloadClickListener(new DownloadClickDispatcher()); - } else if (transferControls.isPresent()) { - getTransferControls().setVisibility(View.GONE); - } - - if (slide.getThumbnailUri() != null && slide.hasPlayOverlay() && - (slide.getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) - { - this.playOverlay.setVisibility(View.VISIBLE); - } else { - this.playOverlay.setVisibility(View.GONE); - } - - if (Util.equals(slide, this.slide)) { - Log.i(TAG, "Not re-loading slide " + slide.asAttachment().getDataUri()); - return new SettableFuture<>(false); - } - - if (this.slide != null && this.slide.getFastPreflightId() != null && - this.slide.getFastPreflightId().equals(slide.getFastPreflightId())) - { - Log.i(TAG, "Not re-loading slide for fast preflight: " + slide.getFastPreflightId()); - this.slide = slide; - return new SettableFuture<>(false); - } - - Log.i(TAG, "loading part with id " + slide.asAttachment().getDataUri() - + ", progress " + slide.getTransferState() + ", fast preflight id: " + - slide.asAttachment().getFastPreflightId()); - - this.slide = slide; - - dimens[WIDTH] = naturalWidth; - dimens[HEIGHT] = naturalHeight; - invalidate(); - - SettableFuture<Boolean> result = new SettableFuture<>(); - - if (slide.getThumbnailUri() != null) { - buildThumbnailGlideRequest(glideRequests, slide).into(new GlideDrawableListeningTarget(image, result)); - } else if (slide.hasPlaceholder()) { - buildPlaceholderGlideRequest(glideRequests, slide).into(new GlideBitmapListeningTarget(image, result)); - } else { - glideRequests.load(R.drawable.ic_image_white_24dp).centerInside().into(image); - result.set(false); - } - - return result; - } - - public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Uri uri) { - SettableFuture<Boolean> future = new SettableFuture<>(); - - if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE); - - GlideRequest request = glideRequests.load(new DecryptableUri(uri)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .transition(withCrossFade()); - - if (radius > 0) { - request = request.transforms(new CenterCrop(), new RoundedCorners(radius)); - } else { - request = request.transforms(new CenterCrop()); - } - - request.into(new GlideDrawableListeningTarget(image, future)); - - return future; - } - - public void setThumbnailClickListener(SlideClickListener listener) { - this.thumbnailClickListener = listener; - } - - public void setDownloadClickListener(SlidesClickedListener listener) { - this.downloadClickListener = listener; - } - - public void clear(GlideRequests glideRequests) { - glideRequests.clear(image); - - if (transferControls.isPresent()) { - getTransferControls().clear(); - } - - slide = null; - } - - public void showDownloadText(boolean showDownloadText) { - getTransferControls().setShowDownloadText(showDownloadText); - } - - public void showProgressSpinner() { - getTransferControls().showProgressSpinner(); - } - - public void setLoadIndicatorVisibile(boolean visible) { - this.loadIndicator.setVisibility(visible ? VISIBLE : GONE); - } - - protected void setRadius(int radius) { - this.radius = radius; - } - - private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) { - GlideRequest request = applySizing(glideRequests.load(new DecryptableUri(slide.getThumbnailUri())) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .transition(withCrossFade()), new CenterCrop()); - - if (slide.isInProgress()) return request; - else return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture)); - } - - private RequestBuilder buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) { - return applySizing(glideRequests.asBitmap() - .load(slide.getPlaceholderRes(getContext().getTheme())) - .diskCacheStrategy(DiskCacheStrategy.NONE), new FitCenter()); - } - - private GlideRequest applySizing(@NonNull GlideRequest request, @NonNull BitmapTransformation fitting) { - int[] size = new int[2]; - fillTargetDimensions(size, dimens, bounds); - if (size[WIDTH] == 0 && size[HEIGHT] == 0) { - size[WIDTH] = getDefaultWidth(); - size[HEIGHT] = getDefaultHeight(); - } - - request = request.override(size[WIDTH], size[HEIGHT]); - - if (radius > 0) { - return request.transforms(fitting, new RoundedCorners(radius)); - } else { - return request.transforms(fitting); - } - } - - private int getDefaultWidth() { - ViewGroup.LayoutParams params = getLayoutParams(); - if (params != null) { - return Math.max(params.width, 0); - } - return 0; - } - - private int getDefaultHeight() { - ViewGroup.LayoutParams params = getLayoutParams(); - if (params != null) { - return Math.max(params.height, 0); - } - return 0; - } - - private class ThumbnailClickDispatcher implements View.OnClickListener { - - @Override - public void onClick(View view) { - if (thumbnailClickListener != null && - slide != null && - slide.asAttachment().getDataUri() != null && - slide.getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE) - { - thumbnailClickListener.onClick(view, slide); - } else if (parentClickListener != null) { - parentClickListener.onClick(view); - } - } - } - - private class DownloadClickDispatcher implements View.OnClickListener { - - @Override - public void onClick(View view) { - if (downloadClickListener != null && slide != null) { - downloadClickListener.onClick(view, Collections.singletonList(slide)); - } else { - Log.w(TAG, "Received a download button click, but unable to execute it. slide: " + String.valueOf(slide) + " downloadClickListener: " + String.valueOf(downloadClickListener)); - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt similarity index 82% rename from app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt rename to app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt index 1ae2902188..4a9986d6ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/KThumbnailView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ThumbnailView.kt @@ -2,14 +2,11 @@ package org.thoughtcrime.securesms.conversation.v2.utilities import android.content.Context import android.graphics.Bitmap -import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.net.Uri import android.util.AttributeSet -import android.view.LayoutInflater import android.view.View import android.widget.FrameLayout -import androidx.core.content.ContextCompat import androidx.core.view.isVisible import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.CenterCrop @@ -29,31 +26,33 @@ import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri import org.thoughtcrime.securesms.mms.GlideRequest import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.Slide +import kotlin.Boolean +import kotlin.Int +import kotlin.getValue +import kotlin.lazy +import kotlin.let -open class KThumbnailView: FrameLayout { - private lateinit var binding: ThumbnailViewBinding +open class ThumbnailView: FrameLayout { companion object { private const val WIDTH = 0 private const val HEIGHT = 1 } + private val binding: ThumbnailViewBinding by lazy { ThumbnailViewBinding.bind(this) } + // region Lifecycle constructor(context: Context) : super(context) { initialize(null) } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize(attrs) } constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize(attrs) } - private val image by lazy { binding.thumbnailImage } - private val playOverlay by lazy { binding.playOverlay } val loadIndicator: View by lazy { binding.thumbnailLoadIndicator } - val downloadIndicator: View by lazy { binding.thumbnailDownloadIcon } private val dimensDelegate = ThumbnailDimensDelegate() private var slide: Slide? = null - private var radius: Int = 0 + var radius: Int = 0 private fun initialize(attrs: AttributeSet?) { - binding = ThumbnailViewBinding.inflate(LayoutInflater.from(context), this) if (attrs != null) { val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ThumbnailView, 0, 0) @@ -66,8 +65,6 @@ open class KThumbnailView: FrameLayout { typedArray.recycle() } - val background = ContextCompat.getColor(context, R.color.transparent_black_6) - binding.root.background = ColorDrawable(background) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -80,8 +77,8 @@ open class KThumbnailView: FrameLayout { val finalHeight: Int = adjustedDimens[HEIGHT] + paddingTop + paddingBottom super.onMeasure( - MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY) + MeasureSpec.makeMeasureSpec(finalWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY) ) } @@ -90,17 +87,17 @@ open class KThumbnailView: FrameLayout { // endregion // region Interaction - fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord): ListenableFuture<Boolean> { + fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, mms: MmsMessageRecord?): ListenableFuture<Boolean> { return setImageResource(glide, slide, isPreview, 0, 0, mms) } fun setImageResource(glide: GlideRequests, slide: Slide, isPreview: Boolean, naturalWidth: Int, - naturalHeight: Int, mms: MmsMessageRecord): ListenableFuture<Boolean> { + naturalHeight: Int, mms: MmsMessageRecord?): ListenableFuture<Boolean> { val currentSlide = this.slide - playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() && + binding.playOverlay.isVisible = (slide.thumbnailUri != null && slide.hasPlayOverlay() && (slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_DONE || isPreview)) if (equals(currentSlide, slide)) { @@ -116,8 +113,8 @@ open class KThumbnailView: FrameLayout { this.slide = slide - loadIndicator.isVisible = slide.isInProgress - downloadIndicator.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED + binding.thumbnailLoadIndicator.isVisible = slide.isInProgress + binding.thumbnailDownloadIcon.isVisible = slide.transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED dimensDelegate.setDimens(naturalWidth, naturalHeight) invalidate() @@ -126,13 +123,13 @@ open class KThumbnailView: FrameLayout { when { slide.thumbnailUri != null -> { - buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(image, result)) + buildThumbnailGlideRequest(glide, slide).into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, result)) } slide.hasPlaceholder() -> { - buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(image, result)) + buildPlaceholderGlideRequest(glide, slide).into(GlideBitmapListeningTarget(binding.thumbnailImage, null, result)) } else -> { - glide.clear(image) + glide.clear(binding.thumbnailImage) result.set(false) } } @@ -176,7 +173,7 @@ open class KThumbnailView: FrameLayout { } open fun clear(glideRequests: GlideRequests) { - glideRequests.clear(image) + glideRequests.clear(binding.thumbnailImage) slide = null } @@ -193,11 +190,8 @@ open class KThumbnailView: FrameLayout { request.transforms(CenterCrop()) } - request.into(GlideDrawableListeningTarget(image, future)) + request.into(GlideDrawableListeningTarget(binding.thumbnailImage, binding.thumbnailLoadIndicator, future)) return future } - - // endregion - } \ No newline at end of file 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/crypto/KeyStoreHelper.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyStoreHelper.java index 43e9865598..7f0edddeb0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyStoreHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/KeyStoreHelper.java @@ -1,13 +1,13 @@ package org.thoughtcrime.securesms.crypto; -import android.os.Build; +import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK; + import android.security.keystore.KeyGenParameterSpec; import android.security.keystore.KeyProperties; import android.util.Base64; import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonGenerator; @@ -45,44 +45,50 @@ public final class KeyStoreHelper { private static final String ANDROID_KEY_STORE = "AndroidKeyStore"; private static final String KEY_ALIAS = "SignalSecret"; - @RequiresApi(Build.VERSION_CODES.M) public static SealedData seal(@NonNull byte[] input) { SecretKey secretKey = getOrCreateKeyStoreEntry(); try { - Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); - cipher.init(Cipher.ENCRYPT_MODE, secretKey); + // Cipher operations are not thread-safe so we synchronize over them through doFinal to + // prevent crashes with quickly repeated encrypt/decrypt operations + // https://github.com/mozilla-mobile/android-components/issues/5342 + synchronized (CIPHER_LOCK) { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); - byte[] iv = cipher.getIV(); - byte[] data = cipher.doFinal(input); + byte[] iv = cipher.getIV(); + byte[] data = cipher.doFinal(input); - return new SealedData(iv, data); + return new SealedData(iv, data); + } } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) { throw new AssertionError(e); } } - @RequiresApi(Build.VERSION_CODES.M) public static byte[] unseal(@NonNull SealedData sealedData) { SecretKey secretKey = getKeyStoreEntry(); try { - Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); - cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, sealedData.iv)); + // Cipher operations are not thread-safe so we synchronize over them through doFinal to + // prevent crashes with quickly repeated encrypt/decrypt operations + // https://github.com/mozilla-mobile/android-components/issues/5342 + synchronized (CIPHER_LOCK) { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, sealedData.iv)); - return cipher.doFinal(sealedData.data); + return cipher.doFinal(sealedData.data); + } } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { throw new AssertionError(e); } } - @RequiresApi(Build.VERSION_CODES.M) private static SecretKey getOrCreateKeyStoreEntry() { if (hasKeyStoreEntry()) return getKeyStoreEntry(); else return createKeyStoreEntry(); } - @RequiresApi(Build.VERSION_CODES.M) private static SecretKey createKeyStoreEntry() { try { KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE); @@ -99,7 +105,6 @@ public final class KeyStoreHelper { } } - @RequiresApi(Build.VERSION_CODES.M) private static SecretKey getKeyStoreEntry() { KeyStore keyStore = getKeyStore(); @@ -137,7 +142,6 @@ public final class KeyStoreHelper { } } - @RequiresApi(Build.VERSION_CODES.M) private static boolean hasKeyStoreEntry() { try { KeyStore ks = KeyStore.getInstance(ANDROID_KEY_STORE); @@ -202,7 +206,5 @@ public final class KeyStoreHelper { return Base64.decode(p.getValueAsString(), Base64.NO_WRAP | Base64.NO_PADDING); } } - } - } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java index 8c9916b87c..45172e2f6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java @@ -33,8 +33,9 @@ import androidx.annotation.VisibleForTesting; import com.bumptech.glide.Glide; -import net.sqlcipher.database.SQLiteDatabase; +import net.zetetic.database.sqlcipher.SQLiteDatabase; +import org.apache.commons.lang3.StringUtils; import org.json.JSONArray; import org.json.JSONException; import org.session.libsession.messaging.sending_receiving.attachments.Attachment; @@ -318,6 +319,28 @@ public class AttachmentDatabase extends Database { notifyAttachmentListeners(); } + @SuppressWarnings("ResultOfMethodCallIgnored") + void deleteAttachmentsForMessages(long[] mmsIds) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + Cursor cursor = null; + String mmsIdString = StringUtils.join(mmsIds, ','); + + try { + cursor = database.query(TABLE_NAME, new String[] {DATA, THUMBNAIL, CONTENT_TYPE}, MMS_ID + " IN (?)", + new String[] {mmsIdString}, null, null, null); + + while (cursor != null && cursor.moveToNext()) { + deleteAttachmentOnDisk(cursor.getString(0), cursor.getString(1), cursor.getString(2)); + } + } finally { + if (cursor != null) + cursor.close(); + } + + database.delete(TABLE_NAME, MMS_ID + " IN (?)", new String[] {mmsIdString}); + notifyAttachmentListeners(); + } + public void deleteAttachment(@NonNull AttachmentId id) { SQLiteDatabase database = databaseHelper.getWritableDatabase(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt new file mode 100644 index 0000000000..19a511bfd6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ConfigDatabase.kt @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.database + +import android.content.Context +import androidx.core.content.contentValuesOf +import androidx.core.database.getBlobOrNull +import androidx.core.database.getLongOrNull +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper + +class ConfigDatabase(context: Context, helper: SQLCipherOpenHelper): Database(context, helper) { + + companion object { + private const val VARIANT = "variant" + private const val PUBKEY = "publicKey" + private const val DATA = "data" + private const val TIMESTAMP = "timestamp" // Milliseconds + + private const val TABLE_NAME = "configs_table" + + const val CREATE_CONFIG_TABLE_COMMAND = + "CREATE TABLE $TABLE_NAME ($VARIANT TEXT NOT NULL, $PUBKEY TEXT NOT NULL, $DATA BLOB, $TIMESTAMP INTEGER NOT NULL DEFAULT 0, PRIMARY KEY($VARIANT, $PUBKEY));" + + private const val VARIANT_AND_PUBKEY_WHERE = "$VARIANT = ? AND $PUBKEY = ?" + } + + fun storeConfig(variant: String, publicKey: String, data: ByteArray, timestamp: Long) { + val db = writableDatabase + val contentValues = contentValuesOf( + VARIANT to variant, + PUBKEY to publicKey, + DATA to data, + TIMESTAMP to timestamp + ) + db.insertOrUpdate(TABLE_NAME, contentValues, VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey)) + } + + fun retrieveConfigAndHashes(variant: String, publicKey: String): ByteArray? { + val db = readableDatabase + val query = db.query(TABLE_NAME, arrayOf(DATA), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null) + return query?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + val bytes = cursor.getBlobOrNull(cursor.getColumnIndex(DATA)) ?: return@use null + bytes + } + } + + fun retrieveConfigLastUpdateTimestamp(variant: String, publicKey: String): Long { + val db = readableDatabase + val cursor = db.query(TABLE_NAME, arrayOf(TIMESTAMP), VARIANT_AND_PUBKEY_WHERE, arrayOf(variant, publicKey),null, null, null) + if (cursor == null) return 0 + if (!cursor.moveToFirst()) return 0 + return (cursor.getLongOrNull(cursor.getColumnIndex(TIMESTAMP)) ?: 0) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/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 ce950214f0..e1879d5230 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java @@ -23,9 +23,10 @@ import android.database.Cursor; import androidx.annotation.NonNull; -import net.sqlcipher.database.SQLiteDatabase; +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/DatabaseFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java index 74396e2a93..76fa8c5c0b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java @@ -19,7 +19,7 @@ package org.thoughtcrime.securesms.database; import android.content.Context; -import net.sqlcipher.database.SQLiteDatabase; +import net.zetetic.database.sqlcipher.SQLiteDatabase; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseUtilities.kt index e6c9b9614e..f4d6530bbf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseUtilities.kt @@ -1,9 +1,9 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues +import android.database.Cursor import androidx.core.database.getStringOrNull -import net.sqlcipher.Cursor -import net.sqlcipher.database.SQLiteDatabase +import net.zetetic.database.sqlcipher.SQLiteDatabase import org.session.libsignal.utilities.Base64 fun <T> SQLiteDatabase.get(table: String, query: String?, arguments: Array<String>?, get: (Cursor) -> T): T? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java index 2dd8b2bf24..822e40129e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java @@ -6,7 +6,7 @@ import android.database.Cursor; import android.net.Uri; import androidx.annotation.Nullable; -import net.sqlcipher.database.SQLiteDatabase; +import net.zetetic.database.sqlcipher.SQLiteDatabase; import network.loki.messenger.R; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; 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/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index feaccc3983..66d01114ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.database; - import android.annotation.SuppressLint; import android.content.ContentValues; import android.content.Context; @@ -12,7 +11,7 @@ import androidx.annotation.Nullable; import com.annimon.stream.Stream; -import net.sqlcipher.database.SQLiteDatabase; +import net.zetetic.database.sqlcipher.SQLiteDatabase; import org.jetbrains.annotations.NotNull; import org.session.libsession.utilities.Address; @@ -37,9 +36,9 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt @SuppressWarnings("unused") private static final String TAG = GroupDatabase.class.getSimpleName(); - static final String TABLE_NAME = "groups"; + public static final String TABLE_NAME = "groups"; private static final String ID = "_id"; - static final String GROUP_ID = "group_id"; + public static final String GROUP_ID = "group_id"; private static final String TITLE = "title"; private static final String MEMBERS = "members"; private static final String ZOMBIE_MEMBERS = "zombie_members"; @@ -134,12 +133,12 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt return new Reader(cursor); } - public List<GroupRecord> getAllGroups() { + public List<GroupRecord> getAllGroups(boolean includeInactive) { Reader reader = getGroups(); GroupRecord record; List<GroupRecord> groups = new LinkedList<>(); while ((record = reader.getNext()) != null) { - if (record.isActive()) { groups.add(record); } + if (record.isActive() || includeInactive) { groups.add(record); } } reader.close(); return groups; @@ -319,6 +318,38 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt notifyConversationListListeners(); } + @Override + public void removeProfilePicture(String groupID) { + databaseHelper.getWritableDatabase() + .execSQL("UPDATE " + TABLE_NAME + + " SET " + AVATAR + " = NULL, " + + AVATAR_ID + " = NULL, " + + AVATAR_KEY + " = NULL, " + + AVATAR_CONTENT_TYPE + " = NULL, " + + AVATAR_RELAY + " = NULL, " + + AVATAR_DIGEST + " = NULL, " + + AVATAR_URL + " = NULL" + + " WHERE " + + GROUP_ID + " = ?", + new String[] {groupID}); + + Recipient.applyCached(Address.fromSerialized(groupID), recipient -> recipient.setGroupAvatarId(null)); + notifyConversationListListeners(); + } + + public boolean hasDownloadedProfilePicture(String groupId) { + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[]{AVATAR}, GROUP_ID + " = ?", + new String[] {groupId}, + null, null, null)) + { + if (cursor != null && cursor.moveToNext()) { + return !cursor.isNull(0); + } + + return false; + } + } + public void updateMembers(String groupId, List<Address> members) { Collections.sort(members); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java index 81f8b62aa5..a6fed5be83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptDatabase.java @@ -1,14 +1,14 @@ package org.thoughtcrime.securesms.database; - import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import androidx.annotation.NonNull; -import net.sqlcipher.database.SQLiteDatabase; +import net.zetetic.database.sqlcipher.SQLiteDatabase; +import org.apache.commons.lang3.StringUtils; import org.session.libsession.utilities.Address; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; @@ -110,6 +110,11 @@ public class GroupReceiptDatabase extends Database { db.delete(TABLE_NAME, MMS_ID + " = ?", new String[] {String.valueOf(mmsId)}); } + void deleteRowsForMessages(long[] mmsIds) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.delete(TABLE_NAME, MMS_ID + " IN (?)", new String[] {StringUtils.join(mmsIds, ',')}); + } + void deleteAllRows() { SQLiteDatabase db = databaseHelper.getWritableDatabase(); db.delete(TABLE_NAME, null, null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/JobDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/JobDatabase.java deleted file mode 100644 index f878e3061a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/JobDatabase.java +++ /dev/null @@ -1,249 +0,0 @@ -package org.thoughtcrime.securesms.database; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import androidx.annotation.NonNull; - -import net.sqlcipher.database.SQLiteDatabase; - -import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; -import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec; -import org.thoughtcrime.securesms.jobmanager.persistence.DependencySpec; -import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec; -import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec; - -import java.util.LinkedList; -import java.util.List; - -public class JobDatabase extends Database { - - public static final String[] CREATE_TABLE = new String[] { Jobs.CREATE_TABLE, - Constraints.CREATE_TABLE, - Dependencies.CREATE_TABLE }; - - public static final class Jobs { - public static final String TABLE_NAME = "job_spec"; - private static final String ID = "_id"; - private static final String JOB_SPEC_ID = "job_spec_id"; - private static final String FACTORY_KEY = "factory_key"; - private static final String QUEUE_KEY = "queue_key"; - private static final String CREATE_TIME = "create_time"; - private static final String NEXT_RUN_ATTEMPT_TIME = "next_run_attempt_time"; - private static final String RUN_ATTEMPT = "run_attempt"; - private static final String MAX_ATTEMPTS = "max_attempts"; - private static final String MAX_BACKOFF = "max_backoff"; - private static final String MAX_INSTANCES = "max_instances"; - private static final String LIFESPAN = "lifespan"; - private static final String SERIALIZED_DATA = "serialized_data"; - private static final String IS_RUNNING = "is_running"; - - private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + - JOB_SPEC_ID + " TEXT UNIQUE, " + - FACTORY_KEY + " TEXT, " + - QUEUE_KEY + " TEXT, " + - CREATE_TIME + " INTEGER, " + - NEXT_RUN_ATTEMPT_TIME + " INTEGER, " + - RUN_ATTEMPT + " INTEGER, " + - MAX_ATTEMPTS + " INTEGER, " + - MAX_BACKOFF + " INTEGER, " + - MAX_INSTANCES + " INTEGER, " + - LIFESPAN + " INTEGER, " + - SERIALIZED_DATA + " TEXT, " + - IS_RUNNING + " INTEGER)"; - } - - public static final class Constraints { - public static final String TABLE_NAME = "constraint_spec"; - private static final String ID = "_id"; - private static final String JOB_SPEC_ID = "job_spec_id"; - private static final String FACTORY_KEY = "factory_key"; - - private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + - JOB_SPEC_ID + " TEXT, " + - FACTORY_KEY + " TEXT, " + - "UNIQUE(" + JOB_SPEC_ID + ", " + FACTORY_KEY + "))"; - } - - public static final class Dependencies { - public static final String TABLE_NAME = "dependency_spec"; - private static final String ID = "_id"; - private static final String JOB_SPEC_ID = "job_spec_id"; - private static final String DEPENDS_ON_JOB_SPEC_ID = "depends_on_job_spec_id"; - - private static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + - JOB_SPEC_ID + " TEXT, " + - DEPENDS_ON_JOB_SPEC_ID + " TEXT, " + - "UNIQUE(" + JOB_SPEC_ID + ", " + DEPENDS_ON_JOB_SPEC_ID + "))"; - } - - - public JobDatabase(Context context, SQLCipherOpenHelper databaseHelper) { - super(context, databaseHelper); - } - - public synchronized void insertJobs(@NonNull List<FullSpec> fullSpecs) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - - db.beginTransaction(); - - try { - for (FullSpec fullSpec : fullSpecs) { - insertJobSpec(db, fullSpec.getJobSpec()); - insertConstraintSpecs(db, fullSpec.getConstraintSpecs()); - insertDependencySpecs(db, fullSpec.getDependencySpecs()); - } - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - } - - public synchronized @NonNull List<JobSpec> getAllJobSpecs() { - List<JobSpec> jobs = new LinkedList<>(); - - try (Cursor cursor = databaseHelper.getReadableDatabase().query(Jobs.TABLE_NAME, null, null, null, null, null, Jobs.CREATE_TIME + ", " + Jobs.ID + " ASC")) { - while (cursor != null && cursor.moveToNext()) { - jobs.add(jobSpecFromCursor(cursor)); - } - } - - return jobs; - } - - public synchronized void updateJobRunningState(@NonNull String id, boolean isRunning) { - ContentValues contentValues = new ContentValues(); - contentValues.put(Jobs.IS_RUNNING, isRunning ? 1 : 0); - - String query = Jobs.JOB_SPEC_ID + " = ?"; - String[] args = new String[]{ id }; - - databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, query, args); - } - - public synchronized void updateJobAfterRetry(@NonNull String id, boolean isRunning, int runAttempt, long nextRunAttemptTime) { - ContentValues contentValues = new ContentValues(); - contentValues.put(Jobs.IS_RUNNING, isRunning ? 1 : 0); - contentValues.put(Jobs.RUN_ATTEMPT, runAttempt); - contentValues.put(Jobs.NEXT_RUN_ATTEMPT_TIME, nextRunAttemptTime); - - String query = Jobs.JOB_SPEC_ID + " = ?"; - String[] args = new String[]{ id }; - - databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, query, args); - } - - public synchronized void updateAllJobsToBePending() { - ContentValues contentValues = new ContentValues(); - contentValues.put(Jobs.IS_RUNNING, 0); - - databaseHelper.getWritableDatabase().update(Jobs.TABLE_NAME, contentValues, null, null); - } - - public synchronized void deleteJobs(@NonNull List<String> jobIds) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - - db.beginTransaction(); - - try { - for (String jobId : jobIds) { - String[] arg = new String[]{jobId}; - - db.delete(Jobs.TABLE_NAME, Jobs.JOB_SPEC_ID + " = ?", arg); - db.delete(Constraints.TABLE_NAME, Constraints.JOB_SPEC_ID + " = ?", arg); - db.delete(Dependencies.TABLE_NAME, Dependencies.JOB_SPEC_ID + " = ?", arg); - db.delete(Dependencies.TABLE_NAME, Dependencies.DEPENDS_ON_JOB_SPEC_ID + " = ?", arg); - } - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - } - - public synchronized @NonNull List<ConstraintSpec> getAllConstraintSpecs() { - List<ConstraintSpec> constraints = new LinkedList<>(); - - try (Cursor cursor = databaseHelper.getReadableDatabase().query(Constraints.TABLE_NAME, null, null, null, null, null, null)) { - while (cursor != null && cursor.moveToNext()) { - constraints.add(constraintSpecFromCursor(cursor)); - } - } - - return constraints; - } - - public synchronized @NonNull List<DependencySpec> getAllDependencySpecs() { - List<DependencySpec> dependencies = new LinkedList<>(); - - try (Cursor cursor = databaseHelper.getReadableDatabase().query(Dependencies.TABLE_NAME, null, null, null, null, null, null)) { - while (cursor != null && cursor.moveToNext()) { - dependencies.add(dependencySpecFromCursor(cursor)); - } - } - - return dependencies; - } - - private void insertJobSpec(@NonNull SQLiteDatabase db, @NonNull JobSpec job) { - ContentValues contentValues = new ContentValues(); - contentValues.put(Jobs.JOB_SPEC_ID, job.getId()); - contentValues.put(Jobs.FACTORY_KEY, job.getFactoryKey()); - contentValues.put(Jobs.QUEUE_KEY, job.getQueueKey()); - contentValues.put(Jobs.CREATE_TIME, job.getCreateTime()); - contentValues.put(Jobs.NEXT_RUN_ATTEMPT_TIME, job.getNextRunAttemptTime()); - contentValues.put(Jobs.RUN_ATTEMPT, job.getRunAttempt()); - contentValues.put(Jobs.MAX_ATTEMPTS, job.getMaxAttempts()); - contentValues.put(Jobs.MAX_BACKOFF, job.getMaxBackoff()); - contentValues.put(Jobs.MAX_INSTANCES, job.getMaxInstances()); - contentValues.put(Jobs.LIFESPAN, job.getLifespan()); - contentValues.put(Jobs.SERIALIZED_DATA, job.getSerializedData()); - contentValues.put(Jobs.IS_RUNNING, job.isRunning() ? 1 : 0); - - db.insertWithOnConflict(Jobs.TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_IGNORE); - } - - private void insertConstraintSpecs(@NonNull SQLiteDatabase db, @NonNull List<ConstraintSpec> constraints) { - for (ConstraintSpec constraintSpec : constraints) { - ContentValues contentValues = new ContentValues(); - contentValues.put(Constraints.JOB_SPEC_ID, constraintSpec.getJobSpecId()); - contentValues.put(Constraints.FACTORY_KEY, constraintSpec.getFactoryKey()); - db.insertWithOnConflict(Constraints.TABLE_NAME, null ,contentValues, SQLiteDatabase.CONFLICT_IGNORE); - } - } - - private void insertDependencySpecs(@NonNull SQLiteDatabase db, @NonNull List<DependencySpec> dependencies) { - for (DependencySpec dependencySpec : dependencies) { - ContentValues contentValues = new ContentValues(); - contentValues.put(Dependencies.JOB_SPEC_ID, dependencySpec.getJobId()); - contentValues.put(Dependencies.DEPENDS_ON_JOB_SPEC_ID, dependencySpec.getDependsOnJobId()); - db.insertWithOnConflict(Dependencies.TABLE_NAME, null, contentValues, SQLiteDatabase.CONFLICT_IGNORE); - } - } - - private @NonNull JobSpec jobSpecFromCursor(@NonNull Cursor cursor) { - return new JobSpec(cursor.getString(cursor.getColumnIndexOrThrow(Jobs.JOB_SPEC_ID)), - cursor.getString(cursor.getColumnIndexOrThrow(Jobs.FACTORY_KEY)), - cursor.getString(cursor.getColumnIndexOrThrow(Jobs.QUEUE_KEY)), - cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.CREATE_TIME)), - cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.NEXT_RUN_ATTEMPT_TIME)), - cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.RUN_ATTEMPT)), - cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.MAX_ATTEMPTS)), - cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.MAX_BACKOFF)), - cursor.getLong(cursor.getColumnIndexOrThrow(Jobs.LIFESPAN)), - cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.MAX_INSTANCES)), - cursor.getString(cursor.getColumnIndexOrThrow(Jobs.SERIALIZED_DATA)), - cursor.getInt(cursor.getColumnIndexOrThrow(Jobs.IS_RUNNING)) == 1); - } - - private @NonNull ConstraintSpec constraintSpecFromCursor(@NonNull Cursor cursor) { - return new ConstraintSpec(cursor.getString(cursor.getColumnIndexOrThrow(Constraints.JOB_SPEC_ID)), - cursor.getString(cursor.getColumnIndexOrThrow(Constraints.FACTORY_KEY))); - } - - private @NonNull DependencySpec dependencySpecFromCursor(@NonNull Cursor cursor) { - return new DependencySpec(cursor.getString(cursor.getColumnIndexOrThrow(Dependencies.JOB_SPEC_ID)), - cursor.getString(cursor.getColumnIndexOrThrow(Dependencies.DEPENDS_ON_JOB_SPEC_ID))); - } -} 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 6aeadc2b7b..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" @@ -300,6 +310,11 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( val lastHash = database.insertOrUpdate(lastMessageHashValueTable2, row, query, arrayOf( snode.toString(), publicKey, namespace.toString() )) } + override fun clearAllLastMessageHashes() { + val database = databaseHelper.writableDatabase + database.delete(lastMessageHashValueTable2, null, null) + } + override fun getReceivedMessageHashValues(publicKey: String, namespace: Int): Set<String>? { val database = databaseHelper.readableDatabase val query = "${Companion.publicKey} = ? AND ${Companion.receivedMessageHashNamespace} = ?" @@ -321,6 +336,11 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( database.insertOrUpdate(receivedMessageHashValuesTable, row, query, arrayOf( publicKey, namespace.toString() )) } + override fun clearReceivedMessageHashValues() { + val database = databaseHelper.writableDatabase + database.delete(receivedMessageHashValuesTable, null, null) + } + override fun getAuthToken(server: String): String? { val database = databaseHelper.readableDatabase return database.get(openGroupAuthTokenTable, "${Companion.server} = ?", wrap(server)) { cursor -> @@ -339,7 +359,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } override fun getLastMessageServerID(room: String, server: String): Long? { - val database = databaseHelper.writableDatabase + val database = databaseHelper.readableDatabase val index = "$server.$room" return database.get(lastMessageServerIDTable, "$lastMessageServerIDTableIndex = ?", wrap(index)) { cursor -> cursor.getInt(lastMessageServerID) @@ -405,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" @@ -448,9 +493,8 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( return ECKeyPair(DjbECPublicKey(keyPair.publicKey.serialize().removingIdPrefixIfNeeded()), DjbECPrivateKey(keyPair.privateKey.serialize())) } - fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) { + fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) { val database = databaseHelper.writableDatabase - val timestamp = Date().time.toString() val index = "$groupPublicKey-$timestamp" val encryptionKeyPairPublicKey = encryptionKeyPair.publicKey.serialize().toHexString().removingIdPrefixIfNeeded() val encryptionKeyPairPrivateKey = encryptionKeyPair.privateKey.serialize().toHexString() @@ -510,7 +554,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } fun getServerCapabilities(serverName: String): List<String> { - val database = databaseHelper.writableDatabase + val database = databaseHelper.readableDatabase return database.get(serverCapabilitiesTable, "$server = ?", wrap(serverName)) { cursor -> cursor.getString(capabilities) }?.split(",") ?: emptyList() @@ -523,7 +567,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } fun getLastInboxMessageId(serverName: String): Long? { - val database = databaseHelper.writableDatabase + val database = databaseHelper.readableDatabase return database.get(lastInboxMessageServerIdTable, "$server = ?", wrap(serverName)) { cursor -> cursor.getInt(lastInboxMessageServerId) }?.toLong() @@ -540,7 +584,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( } fun getLastOutboxMessageId(serverName: String): Long? { - val database = databaseHelper.writableDatabase + val database = databaseHelper.readableDatabase return database.get(lastOutboxMessageServerIdTable, "$server = ?", wrap(serverName)) { cursor -> cursor.getInt(lastOutboxMessageServerId) }?.toLong() 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 3cfdd13017..18dd42818d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiMessageDatabase.kt @@ -2,8 +2,9 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context -import net.sqlcipher.database.SQLiteDatabase.CONFLICT_REPLACE +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() @@ -77,6 +89,25 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab database.endTransaction() } + fun deleteMessages(messageIDs: List<Long>) { + val database = databaseHelper.writableDatabase + database.beginTransaction() + + database.delete( + messageIDTable, + "${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})", + messageIDs.map { "$it" }.toTypedArray() + ) + database.delete( + messageThreadMappingTable, + "${Companion.messageID} IN (${messageIDs.map { "?" }.joinToString(",")})", + messageIDs.map { "$it" }.toTypedArray() + ) + + database.setTransactionSuccessful() + database.endTransaction() + } + /** * @return pair of sms or mms table-specific ID and whether it is in SMS table */ @@ -96,6 +127,37 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab } } + fun getMessageIDs(serverIDs: List<Long>, threadID: Long): Pair<List<Long>, List<Long>> { + val database = databaseHelper.readableDatabase + + // Retrieve the message ids + val messageIdCursor = database + .rawQuery( + """ + SELECT ${messageThreadMappingTable}.${messageID}, ${messageIDTable}.${messageType} + FROM ${messageThreadMappingTable} + JOIN ${messageIDTable} ON ${messageIDTable}.message_id = ${messageThreadMappingTable}.${messageID} + WHERE ( + ${messageThreadMappingTable}.${Companion.threadID} = $threadID AND + ${messageThreadMappingTable}.${Companion.serverID} IN (${serverIDs.joinToString(",")}) + ) + """ + ) + + val smsMessageIds: MutableList<Long> = mutableListOf() + val mmsMessageIds: MutableList<Long> = mutableListOf() + while (messageIdCursor.moveToNext()) { + if (messageIdCursor.getInt(1) == SMS_TYPE) { + smsMessageIds.add(messageIdCursor.getLong(0)) + } + else { + mmsMessageIds.add(messageIdCursor.getLong(0)) + } + } + + return Pair(smsMessageIds, mmsMessageIds) + } + override fun setServerID(messageID: Long, serverID: Long, isSms: Boolean) { val database = databaseHelper.writableDatabase val contentValues = ContentValues(3) @@ -136,6 +198,11 @@ class LokiMessageDatabase(context: Context, helper: SQLCipherOpenHelper) : Datab database.insertOrUpdate(errorMessageTable, contentValues, "${Companion.messageID} = ?", arrayOf(messageID.toString())) } + fun clearErrorMessage(messageID: Long) { + val database = databaseHelper.writableDatabase + database.delete(errorMessageTable, "${Companion.messageID} = ?", arrayOf(messageID.toString())) + } + fun deleteThread(threadId: Long) { val database = databaseHelper.writableDatabase try { @@ -146,43 +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 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())) + fun deleteMessageServerHashes(messageIDs: List<Long>, mms: Boolean) { + databaseHelper.writableDatabase.delete( + getMessageTable(mms), + "${Companion.messageID} IN (${messageIDs.joinToString(",") { "?" }})", + messageIDs.map { "$it" }.toTypedArray() + ) } + 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/LokiThreadDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt index 300217faba..1cbbf34c9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiThreadDatabase.kt @@ -4,11 +4,8 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import org.session.libsession.messaging.open_groups.OpenGroup -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.JsonUtil import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper -import org.thoughtcrime.securesms.dependencies.DatabaseComponent class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { @@ -24,12 +21,6 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa val createPublicChatTableCommand = "CREATE TABLE $publicChatTable ($threadID INTEGER PRIMARY KEY, $publicChat TEXT);" } - fun getThreadID(hexEncodedPublicKey: String): Long { - val address = Address.fromSerialized(hexEncodedPublicKey) - val recipient = Recipient.from(context, address, false) - return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) - } - fun getAllOpenGroups(): Map<Long, OpenGroup> { val database = databaseHelper.readableDatabase var cursor: Cursor? = null @@ -61,6 +52,13 @@ class LokiThreadDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa } } + fun getThreadId(openGroup: OpenGroup): Long? { + val database = databaseHelper.readableDatabase + return database.get(publicChatTable, "$publicChat = ?", arrayOf(JsonUtil.toJson(openGroup.toJson()))) { cursor -> + cursor.getLong(threadID) + } + } + fun setOpenGroupChat(openGroup: OpenGroup, threadID: Long) { if (threadID < 0) { return diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/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 f16d663a10..63db0c66ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java @@ -7,7 +7,7 @@ import android.database.Cursor; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import net.sqlcipher.database.SQLiteDatabase; +import net.zetetic.database.sqlcipher.SQLiteDatabase; import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment; import org.session.libsession.utilities.Address; @@ -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 ffde5ca029..bc74496dda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -5,7 +5,7 @@ import android.content.Context; import android.database.Cursor; import android.text.TextUtils; -import net.sqlcipher.database.SQLiteDatabase; +import net.zetetic.database.sqlcipher.SQLiteDatabase; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Document; @@ -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,15 +34,22 @@ 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); + + public abstract void markAsSyncing(long id); + + public abstract void markAsResyncing(long id); + + public abstract void markAsSyncFailed(long id); + public abstract void markUnidentified(long messageId, boolean unidentified); - public abstract void markAsDeleted(long messageId, boolean read); + public abstract void markAsDeleted(long messageId, boolean read, boolean hasMention); public abstract boolean deleteMessage(long messageId); + public abstract boolean deleteMessages(long[] messageId, long threadId); public abstract void updateThreadId(long fromId, long toId); @@ -198,7 +206,6 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn contentValues.put(THREAD_ID, newThreadId); db.update(getTableName(), contentValues, where, args); } - public static class SyncMessageId { private final Address address; @@ -218,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 d82c6bb278..5648cdace1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -20,13 +20,13 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import com.annimon.stream.Stream -import com.google.android.mms.pdu_alt.NotificationInd import com.google.android.mms.pdu_alt.PduHeaders +import org.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.OutgoingExpirationUpdateMessage import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingSecureMediaMessage @@ -35,21 +35,19 @@ 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.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel +import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.UNKNOWN import org.session.libsession.utilities.Address.Companion.fromExternal import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.Contact -import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID import org.session.libsession.utilities.IdentityKeyMismatch import org.session.libsession.utilities.IdentityKeyMismatchList import org.session.libsession.utilities.NetworkFailure import org.session.libsession.utilities.NetworkFailureList import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled import org.session.libsession.utilities.Util.toIsoBytes -import org.session.libsession.utilities.Util.toIsoString import org.session.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.recipients.RecipientFormattingException import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils.queue @@ -59,11 +57,13 @@ import org.thoughtcrime.securesms.database.SmsDatabase.InsertListener import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord import org.thoughtcrime.securesms.database.model.Quote import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get import org.thoughtcrime.securesms.mms.MmsException import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.util.asSequence import java.io.Closeable import java.io.IOException import java.security.SecureRandom @@ -90,54 +90,22 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa return 0 } - fun addFailures(messageId: Long, failure: List<NetworkFailure>) { - try { - addToDocument(messageId, NETWORK_FAILURE, failure, NetworkFailureList::class.java) - } catch (e: IOException) { - Log.w(TAG, e) + fun isOutgoingMessage(timestamp: Long): Boolean = + databaseHelper.writableDatabase.query( + TABLE_NAME, + arrayOf(ID, THREAD_ID, MESSAGE_BOX, ADDRESS), + DATE_SENT + " = ?", + arrayOf(timestamp.toString()), + null, + null, + null, + null + ).use { cursor -> + cursor.asSequence() + .map { cursor.getColumnIndexOrThrow(MESSAGE_BOX) } + .map(cursor::getLong) + .any { MmsSmsColumns.Types.isOutgoingMessageType(it) } } - } - - fun removeFailure(messageId: Long, failure: NetworkFailure?) { - try { - removeFromDocument(messageId, NETWORK_FAILURE, failure, NetworkFailureList::class.java) - } catch (e: IOException) { - Log.w(TAG, e) - } - } - - fun isOutgoingMessage(timestamp: Long): Boolean { - val database = databaseHelper.writableDatabase - var cursor: Cursor? = null - var isOutgoing = false - try { - cursor = database.query( - TABLE_NAME, - arrayOf<String>(ID, THREAD_ID, MESSAGE_BOX, ADDRESS), - DATE_SENT + " = ?", - arrayOf(timestamp.toString()), - null, - null, - null, - null - ) - while (cursor.moveToNext()) { - if (MmsSmsColumns.Types.isOutgoingMessageType( - cursor.getLong( - cursor.getColumnIndexOrThrow( - MESSAGE_BOX - ) - ) - ) - ) { - isOutgoing = true - } - } - } finally { - cursor?.close() - } - return isOutgoing - } fun incrementReceiptCount( messageId: SyncMessageId, @@ -191,7 +159,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) get(context).groupReceiptDatabase() .update(ourAddress, id, status, timestamp) - get(context).threadDatabase().update(threadId, false) + get(context).threadDatabase().update(threadId, false, true) notifyConversationListeners(threadId) } } @@ -234,34 +202,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } } - @Throws(RecipientFormattingException::class, MmsException::class) - private fun getThreadIdFor(retrieved: IncomingMediaMessage): Long { - return if (retrieved.groupId != null) { - val groupRecipients = Recipient.from( - context, - retrieved.groupId, - true - ) - get(context).threadDatabase().getOrCreateThreadIdFor(groupRecipients) - } else { - val sender = Recipient.from( - context, - retrieved.from, - true - ) - get(context).threadDatabase().getOrCreateThreadIdFor(sender) - } - } - - private fun getThreadIdFor(notification: NotificationInd): Long { - val fromString = - if (notification.from != null && notification.from.textString != null) toIsoString( - notification.from.textString - ) else "" - val recipient = Recipient.from(context, fromExternal(context, fromString), false) - return get(context).threadDatabase().getOrCreateThreadIdFor(recipient) - } - private fun rawQuery(where: String, arguments: Array<String>?): Cursor { val database = databaseHelper.readableDatabase return database.rawQuery( @@ -272,13 +212,9 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) } - fun getMessages(idsAsString: String): Cursor { - return rawQuery(idsAsString, null) - } - fun getMessage(messageId: Long): Cursor { val cursor = rawQuery(RAW_ID_WHERE, arrayOf(messageId.toString())) - setNotifyConverationListeners(cursor, getThreadIdForMessage(messageId)) + setNotifyConversationListeners(cursor, getThreadIdForMessage(messageId)) return cursor } @@ -288,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, @@ -301,52 +243,44 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa " WHERE " + ID + " = ?", arrayOf(id.toString() + "") ) if (threadId.isPresent) { - get(context).threadDatabase().update(threadId.get(), false) + get(context).threadDatabase().update(threadId.get(), false, true) } } - fun markAsPendingInsecureSmsFallback(messageId: Long) { - val threadId = getThreadIdForMessage(messageId) + private fun markAs( + messageId: Long, + baseType: Long, + threadId: Long = getThreadIdForMessage(messageId) + ) { updateMailboxBitmask( messageId, MmsSmsColumns.Types.BASE_TYPE_MASK, - MmsSmsColumns.Types.BASE_PENDING_INSECURE_SMS_FALLBACK, + baseType, Optional.of(threadId) ) notifyConversationListeners(threadId) } + override fun markAsSyncing(messageId: Long) { + markAs(messageId, MmsSmsColumns.Types.BASE_SYNCING_TYPE) + } + override fun markAsResyncing(messageId: Long) { + markAs(messageId, MmsSmsColumns.Types.BASE_RESYNCING_TYPE) + } + override fun markAsSyncFailed(messageId: Long) { + markAs(messageId, MmsSmsColumns.Types.BASE_SYNC_FAILED_TYPE) + } + fun markAsSending(messageId: Long) { - val threadId = getThreadIdForMessage(messageId) - updateMailboxBitmask( - messageId, - MmsSmsColumns.Types.BASE_TYPE_MASK, - MmsSmsColumns.Types.BASE_SENDING_TYPE, - Optional.of(threadId) - ) - notifyConversationListeners(threadId) + markAs(messageId, MmsSmsColumns.Types.BASE_SENDING_TYPE) } fun markAsSentFailed(messageId: Long) { - val threadId = getThreadIdForMessage(messageId) - updateMailboxBitmask( - messageId, - MmsSmsColumns.Types.BASE_TYPE_MASK, - MmsSmsColumns.Types.BASE_SENT_FAILED_TYPE, - Optional.of(threadId) - ) - notifyConversationListeners(threadId) + markAs(messageId, MmsSmsColumns.Types.BASE_SENT_FAILED_TYPE) } override fun markAsSent(messageId: Long, secure: Boolean) { - val threadId = getThreadIdForMessage(messageId) - updateMailboxBitmask( - messageId, - MmsSmsColumns.Types.BASE_TYPE_MASK, - MmsSmsColumns.Types.BASE_SENT_TYPE or if (secure) MmsSmsColumns.Types.PUSH_MESSAGE_BIT or MmsSmsColumns.Types.SECURE_MESSAGE_BIT else 0, - Optional.of(threadId) - ) - notifyConversationListeners(threadId) + markAs(messageId, MmsSmsColumns.Types.BASE_SENT_TYPE or if (secure) MmsSmsColumns.Types.PUSH_MESSAGE_BIT or MmsSmsColumns.Types.SECURE_MESSAGE_BIT else 0) } override fun markUnidentified(messageId: Long, unidentified: Boolean) { @@ -356,29 +290,18 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) } - override fun markAsDeleted(messageId: Long, read: Boolean) { + override fun markAsDeleted(messageId: Long, read: Boolean, hasMention: Boolean) { val database = databaseHelper.writableDatabase val contentValues = ContentValues() contentValues.put(READ, 1) contentValues.put(BODY, "") + contentValues.put(HAS_MENTION, 0) database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) val attachmentDatabase = get(context).attachmentDatabase() queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) }) val threadId = getThreadIdForMessage(messageId) - if (!read) { - get(context).threadDatabase().decrementUnread(threadId, 1) - } - updateMailboxBitmask( - messageId, - MmsSmsColumns.Types.BASE_TYPE_MASK, - MmsSmsColumns.Types.BASE_DELETED_TYPE, - Optional.of(threadId) - ) - notifyConversationListeners(threadId) - } - override fun markExpireStarted(messageId: Long) { - markExpireStarted(messageId, System.currentTimeMillis()) + markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId) } override fun markExpireStarted(messageId: Long, startedTimestamp: Long) { @@ -397,6 +320,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(id.toString())) } + fun setMessagesRead(threadId: Long, beforeTime: Long): List<MarkedMessageInfo> { + return setMessagesRead( + THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1) AND " + DATE_SENT + " <= ?", + arrayOf(threadId.toString(), beforeTime.toString()) + ) + } + fun setMessagesRead(threadId: Long): List<MarkedMessageInfo> { return setMessagesRead( THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", @@ -404,10 +334,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) } - fun setAllMessagesRead(): List<MarkedMessageInfo> { - return setMessagesRead(READ + " = 0", null) - } - private fun setMessagesRead(where: String, arguments: Array<String>?): List<MarkedMessageInfo> { val database = databaseHelper.writableDatabase val result: MutableList<MarkedMessageInfo> = LinkedList() @@ -416,7 +342,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa try { cursor = database.query( TABLE_NAME, - arrayOf<String>(ID, ADDRESS, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED), + arrayOf(ID, ADDRESS, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED), where, arguments, null, @@ -425,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)) } @@ -461,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) @@ -529,6 +457,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa timestamp, subscriptionId, expiresIn, + expireStartedAt, distributionType, quote, contacts, @@ -625,18 +554,10 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa contentLocation: String, threadId: Long, mailbox: Long, serverTimestamp: Long, - runIncrement: Boolean, runThreadUpdate: Boolean ): Optional<InsertResult> { - var threadId = threadId - if (threadId == -1L || retrieved.isGroupMessage) { - try { - threadId = getThreadIdFor(retrieved) - } catch (e: RecipientFormattingException) { - Log.w("MmsDatabase", e) - if (threadId == -1L) throw MmsException(e) - } - } + if (threadId < 0 ) throw MmsException("No thread ID supplied!") + 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()) @@ -657,8 +578,9 @@ 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) if (!contentValues.containsKey(DATE_SENT)) { contentValues.put(DATE_SENT, contentValues.getAsLong(DATE_RECEIVED)) @@ -689,11 +611,8 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa null, ) if (!MmsSmsColumns.Types.isExpirationTimerUpdate(mailbox)) { - if (runIncrement) { - get(context).threadDatabase().incrementUnread(threadId, 1) - } if (runThreadUpdate) { - get(context).threadDatabase().update(threadId, true) + get(context).threadDatabase().update(threadId, true, true) } } notifyConversationListeners(threadId) @@ -707,29 +626,11 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa serverTimestamp: Long, runThreadUpdate: Boolean ): Optional<InsertResult> { - var threadId = threadId - if (threadId == -1L) { - if (retrieved.isGroup) { - val decodedGroupId: String = if (retrieved is OutgoingExpirationUpdateMessage) { - retrieved.groupId - } else { - (retrieved as OutgoingGroupMediaMessage).groupId - } - val groupId: String - groupId = try { - doubleEncodeGroupID(decodedGroupId) - } catch (e: IOException) { - Log.e(TAG, "Couldn't encrypt group ID") - throw MmsException(e) - } - val group = Recipient.from(context, fromSerialized(groupId), false) - threadId = get(context).threadDatabase().getOrCreateThreadIdFor(group) - } else { - threadId = get(context).threadDatabase().getOrCreateThreadIdFor(retrieved.recipient) - } - } + if (threadId < 0 ) throw MmsException("No thread ID supplied!") + 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) @@ -742,7 +643,6 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa retrieved: IncomingMediaMessage, threadId: Long, serverTimestamp: Long = 0, - runIncrement: Boolean, runThreadUpdate: Boolean ): Optional<InsertResult> { var type = MmsSmsColumns.Types.BASE_INBOX_TYPE or MmsSmsColumns.Types.SECURE_MESSAGE_BIT @@ -761,7 +661,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa if (retrieved.isMessageRequestResponse) { type = type or MmsSmsColumns.Types.MESSAGE_REQUEST_RESPONSE_BIT } - return insertMessageInbox(retrieved, "", threadId, type, serverTimestamp, runIncrement, runThreadUpdate) + return insertMessageInbox(retrieved, "", threadId, type, serverTimestamp, runThreadUpdate) } @JvmOverloads @@ -794,11 +694,12 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa // In open groups messages should be sorted by their server timestamp var receivedTimestamp = serverTimestamp if (serverTimestamp == 0L) { - receivedTimestamp = System.currentTimeMillis() + receivedTimestamp = SnodeAPI.nowWithOffset } 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, @@ -850,10 +751,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa ) } with (get(context).threadDatabase()) { - setLastSeen(threadId) + val lastSeen = getLastSeenAndHasSent(threadId).first() + if (lastSeen < message.sentTimeMillis) { + setLastSeen(threadId, message.sentTimeMillis) + } setHasSent(threadId, true) if (runThreadUpdate) { - update(threadId, true) + update(threadId, true, true) } } return messageId @@ -956,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) { @@ -980,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() @@ -988,7 +896,25 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa groupReceiptDatabase.deleteRowsForMessage(messageId) val database = databaseHelper.writableDatabase database!!.delete(TABLE_NAME, ID_WHERE, arrayOf(messageId.toString())) - val threadDeleted = get(context).threadDatabase().update(threadId, false) + val threadDeleted = get(context).threadDatabase().update(threadId, false, true) + notifyConversationListeners(threadId) + notifyStickerListeners() + notifyStickerPackListeners() + return threadDeleted + } + + override fun deleteMessages(messageIds: LongArray, threadId: Long): Boolean { + val argsArray = messageIds.map { "?" } + val argValues = messageIds.map { it.toString() }.toTypedArray() + + 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) notifyStickerListeners() notifyStickerPackListeners() @@ -1169,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() @@ -1186,7 +1111,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa } val threadDb = get(context).threadDatabase() for (threadId in threadIds) { - val threadDeleted = threadDb.update(threadId, false) + val threadDeleted = threadDb.update(threadId, false, true) notifyConversationListeners(threadId) } notifyStickerListeners() @@ -1222,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() @@ -1242,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 @@ -1256,7 +1191,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val slideDeck = SlideDeck(context, message!!.attachments) return MediaMmsMessageRecord( id, message.recipient, message.recipient, - 1, System.currentTimeMillis(), System.currentTimeMillis(), + 1, SnodeAPI.nowWithOffset, SnodeAPI.nowWithOffset, 0, threadId, message.body, slideDeck, slideDeck.slides.size, if (message.isSecure) MmsSmsColumns.Types.getOutgoingEncryptedMessageType() else MmsSmsColumns.Types.getOutgoingSmsMessageType(), @@ -1264,7 +1199,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa LinkedList(), message.subscriptionId, message.expiresIn, - System.currentTimeMillis(), 0, + SnodeAPI.nowWithOffset, 0, if (message.outgoingQuote != null) Quote( message.outgoingQuote!!.id, message.outgoingQuote!!.author, @@ -1272,13 +1207,13 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa message.outgoingQuote!!.missing, SlideDeck(context, message.outgoingQuote!!.attachments!!) ) else null, - message.sharedContacts, message.linkPreviews, listOf(), false + message.sharedContacts, message.linkPreviews, listOf(), false, false ) } } - 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 @@ -1287,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) } } @@ -1314,30 +1249,21 @@ 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)) - 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 readReceiptCount = if (isReadReceiptsEnabled(context)) cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) else 0 + val hasMention = (cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1) + 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, dateSent, dateReceived, deliveryReceiptCount, threadId, contentLocationBytes, messageSize, expiry, status, transactionIdBytes, mailbox, slideDeck, - readReceiptCount + readReceiptCount, hasMention ) } - 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( @@ -1367,6 +1293,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)) val expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED)) val unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED)) == 1 + val hasMention = cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1 if (!isReadReceiptsEnabled(context)) { readReceiptCount = 0 } @@ -1376,34 +1303,25 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa val attachments = get(context).attachmentDatabase().getAttachment( cursor ) - val contacts: List<Contact?> = getSharedContacts( - cursor, attachments - ) - val contactAttachments = - contacts.map { obj: Contact? -> obj!!.avatarAttachment } - .filter { a: Attachment? -> a != null } - .toSet() - val previews: List<LinkPreview?> = getLinkPreviews( - cursor, attachments - ) - val previewAttachments = - previews.filter { lp: LinkPreview? -> lp!!.getThumbnail().isPresent } - .map { lp: LinkPreview? -> lp!!.getThumbnail().get() } - .toSet() + val contacts: List<Contact?> = getSharedContacts(cursor, attachments) + val contactAttachments: Set<Attachment?> = + contacts.mapNotNull { it?.avatarAttachment }.toSet() + val previews: List<LinkPreview?> = getLinkPreviews(cursor, attachments) + val previewAttachments: Set<Attachment?> = + previews.mapNotNull { it?.getThumbnail()?.orNull() }.toSet() val slideDeck = getSlideDeck( - Stream.of(attachments) - .filterNot { o: DatabaseAttachment? -> contactAttachments.contains(o) } - .filterNot { o: DatabaseAttachment? -> previewAttachments.contains(o) } - .toList() + attachments + .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, addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, threadId, body, slideDeck!!, partCount, box, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, - readReceiptCount, quote, contacts, previews, reactions, unidentified + readReceiptCount, quote, contacts, previews, reactions, unidentified, hasMention ) } @@ -1449,14 +1367,16 @@ 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 attachments = get(context).attachmentDatabase().getAttachment(cursor) - val quoteAttachments: List<Attachment?>? = - Stream.of(attachments).filter { obj: DatabaseAttachment? -> obj!!.isQuote } + val quoteDeck = ( + (retrievedQuote as? MmsMessageRecord)?.slideDeck ?: + Stream.of(get(context).attachmentDatabase().getAttachment(cursor)) + .filter { obj: DatabaseAttachment? -> obj!!.isQuote } .toList() - val quoteDeck = SlideDeck(context, quoteAttachments!!) + .let { SlideDeck(context, it) } + ) return Quote( quoteId, fromExternal(context, quoteAuthor), @@ -1493,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, " + @@ -1556,6 +1476,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, + HAS_MENTION, "json_group_array(json_object(" + "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + "'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + @@ -1596,5 +1517,22 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa const val CREATE_MESSAGE_REQUEST_RESPONSE_COMMAND = "ALTER TABLE $TABLE_NAME ADD COLUMN $MESSAGE_REQUEST_RESPONSE INTEGER DEFAULT 0;" 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" + ) } -} \ No newline at end of file +} 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 c4fe3d2437..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"; @@ -24,6 +28,8 @@ public interface MmsSmsColumns { public static final String REACTIONS_UNREAD = "reactions_unread"; public static final String REACTIONS_LAST_SEEN = "reactions_last_seen"; + public static final String HAS_MENTION = "has_mention"; + public static class Types { protected static final long TOTAL_MASK = 0xFFFFFFFF; @@ -45,8 +51,13 @@ public interface MmsSmsColumns { protected static final long BASE_PENDING_INSECURE_SMS_FALLBACK = 26; public static final long BASE_DRAFT_TYPE = 27; protected static final long BASE_DELETED_TYPE = 28; + protected static final long BASE_SYNCING_TYPE = 29; + protected static final long BASE_RESYNCING_TYPE = 30; + protected static final long BASE_SYNC_FAILED_TYPE = 31; protected static final long[] OUTGOING_MESSAGE_TYPES = {BASE_OUTBOX_TYPE, BASE_SENT_TYPE, + BASE_SYNCING_TYPE, BASE_RESYNCING_TYPE, + BASE_SYNC_FAILED_TYPE, BASE_SENDING_TYPE, BASE_SENT_FAILED_TYPE, BASE_PENDING_SECURE_SMS_FALLBACK, BASE_PENDING_INSECURE_SMS_FALLBACK, @@ -107,6 +118,18 @@ public interface MmsSmsColumns { return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE; } + public static boolean isResyncingType(long type) { + return (type & BASE_TYPE_MASK) == BASE_RESYNCING_TYPE; + } + + public static boolean isSyncingType(long type) { + return (type & BASE_TYPE_MASK) == BASE_SYNCING_TYPE; + } + + public static boolean isSyncFailedMessageType(long type) { + return (type & BASE_TYPE_MASK) == BASE_SYNC_FAILED_TYPE; + } + public static boolean isFailedMessageType(long type) { return (type & BASE_TYPE_MASK) == BASE_SENT_FAILED_TYPE; } 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 3fcb1e724c..b737be855e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -16,17 +16,21 @@ */ package org.thoughtcrime.securesms.database; +import static org.thoughtcrime.securesms.database.MmsDatabase.MESSAGE_BOX; + import android.content.Context; import android.database.Cursor; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import net.sqlcipher.database.SQLiteDatabase; -import net.sqlcipher.database.SQLiteQueryBuilder; +import net.zetetic.database.sqlcipher.SQLiteDatabase; +import net.zetetic.database.sqlcipher.SQLiteQueryBuilder; +import org.jetbrains.annotations.NotNull; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Util; +import org.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; @@ -36,6 +40,8 @@ import java.io.Closeable; import java.util.HashSet; import java.util.Set; +import kotlin.Pair; + public class MmsSmsDatabase extends Database { @SuppressWarnings("unused") @@ -75,7 +81,9 @@ public class MmsSmsDatabase extends Database { MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.SHARED_CONTACTS, MmsDatabase.LINK_PREVIEWS, - ReactionDatabase.REACTION_JSON_ALIAS}; + ReactionDatabase.REACTION_JSON_ALIAS, + MmsSmsColumns.HAS_MENTION + }; public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { super(context, databaseHelper); @@ -89,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); @@ -108,17 +120,122 @@ 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()); } + public long getPreviousPage(long threadId, long fromTime, int limit) { + String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" ASC"; + String selection = MmsSmsColumns.THREAD_ID+" = "+threadId + + " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" > "+fromTime; + String limitStr = ""+limit; + long sent = -1; + Cursor cursor = queryTables(PROJECTION, selection, order, limitStr); + if (cursor == null) return sent; + Reader reader = readerFor(cursor); + if (!cursor.move(limit)) { + cursor.moveToLast(); + } + MessageRecord record = reader.getCurrent(); + sent = record.getDateSent(); + reader.close(); + return sent; + } + + public Cursor getConversationPage(long threadId, long fromTime, long toTime, int limit) { + String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" DESC"; + String selection = MmsSmsColumns.THREAD_ID + " = "+threadId + + " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" <= " + fromTime; + String limitStr = null; + if (toTime != -1L) { + selection += " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" > "+toTime; + } else { + limitStr = ""+limit; + } + + return queryTables(PROJECTION, selection, order, limitStr); + } + + public boolean hasNextPage(long threadId, long toTime) { + String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" DESC"; + String selection = MmsSmsColumns.THREAD_ID + " = "+threadId + + " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" < " + toTime; // check if there's at least one message before the `toTime` + Cursor cursor = queryTables(PROJECTION, selection, order, null); + boolean hasNext = false; + if (cursor != null) { + hasNext = cursor.getCount() > 0; + cursor.close(); + } + return hasNext; + } + + public boolean hasPreviousPage(long threadId, long fromTime) { + String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" DESC"; + String selection = MmsSmsColumns.THREAD_ID + " = "+threadId + + " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" > " + fromTime; // check if there's at least one message after the `fromTime` + Cursor cursor = queryTables(PROJECTION, selection, order, null); + boolean hasNext = false; + if (cursor != null) { + hasNext = cursor.getCount() > 0; + cursor.close(); + } + return hasNext; + } + public Cursor getConversation(long threadId, boolean reverse, long offset, long limit) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + (reverse ? " DESC" : " ASC"); String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null; Cursor cursor = queryTables(PROJECTION, selection, order, limitStr); - setNotifyConverationListeners(cursor, threadId); + setNotifyConversationListeners(cursor, threadId); return cursor; } @@ -128,7 +245,7 @@ public class MmsSmsDatabase extends Database { } public Cursor getConversationSnippet(long threadId) { - String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC"; + String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; return queryTables(PROJECTION, selection, order, null); @@ -144,8 +261,81 @@ 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_RECEIVED + " ASC"; + String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " ASC"; String selection = "(" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + " = 1) AND " + MmsSmsColumns.NOTIFIED + " = 0"; return queryTables(PROJECTION, selection, order, null); @@ -180,7 +370,7 @@ public class MmsSmsDatabase extends Database { } public int getQuotedMessagePosition(long threadId, long quoteId, @NonNull Address address) { - String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC"; + String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) { @@ -199,16 +389,16 @@ public class MmsSmsDatabase extends Database { return -1; } - public int getMessagePositionInConversation(long threadId, long receivedTimestamp, @NonNull Address address) { - String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC"; + public int getMessagePositionInConversation(long threadId, long sentTimestamp, @NonNull Address address, boolean reverse) { + String order = MmsSmsColumns.NORMALIZED_DATE_SENT + (reverse ? " DESC" : " ASC"); String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_RECEIVED, MmsSmsColumns.ADDRESS }, selection, order, null)) { + try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) { String serializedAddress = address.serialize(); boolean isOwnNumber = Util.isOwnNumber(context, address.serialize()); while (cursor != null && cursor.moveToNext()) { - boolean timestampMatches = cursor.getLong(0) == receivedTimestamp; + boolean timestampMatches = cursor.getLong(0) == sentTimestamp; boolean addressMatches = serializedAddress.equals(cursor.getString(1)); if (timestampMatches && (addressMatches || isOwnNumber)) { @@ -279,7 +469,9 @@ public class MmsSmsDatabase extends Database { MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.SHARED_CONTACTS, - MmsDatabase.LINK_PREVIEWS}; + MmsDatabase.LINK_PREVIEWS, + MmsSmsColumns.HAS_MENTION + }; String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, @@ -306,7 +498,9 @@ public class MmsSmsDatabase extends Database { MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.SHARED_CONTACTS, - MmsDatabase.LINK_PREVIEWS}; + MmsDatabase.LINK_PREVIEWS, + MmsSmsColumns.HAS_MENTION + }; SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder(); SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder(); @@ -350,6 +544,7 @@ public class MmsSmsDatabase extends Database { mmsColumnsPresent.add(MmsDatabase.STATUS); mmsColumnsPresent.add(MmsDatabase.UNIDENTIFIED); mmsColumnsPresent.add(MmsDatabase.NETWORK_FAILURE); + mmsColumnsPresent.add(MmsSmsColumns.HAS_MENTION); mmsColumnsPresent.add(AttachmentDatabase.ROW_ID); mmsColumnsPresent.add(AttachmentDatabase.UNIQUE_ID); @@ -412,6 +607,7 @@ public class MmsSmsDatabase extends Database { smsColumnsPresent.add(SmsDatabase.DATE_RECEIVED); smsColumnsPresent.add(SmsDatabase.STATUS); smsColumnsPresent.add(SmsDatabase.UNIDENTIFIED); + smsColumnsPresent.add(MmsSmsColumns.HAS_MENTION); smsColumnsPresent.add(ReactionDatabase.ROW_ID); smsColumnsPresent.add(ReactionDatabase.MESSAGE_ID); smsColumnsPresent.add(ReactionDatabase.IS_MMS); @@ -443,17 +639,40 @@ 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 + public Pair<Boolean, Long> timestampAndDirectionForCurrent(@NotNull Cursor cursor) { + int sentColumn = cursor.getColumnIndex(MmsSmsColumns.NORMALIZED_DATE_SENT); + String msgType = cursor.getString(cursor.getColumnIndexOrThrow(TRANSPORT)); + long sentTime = cursor.getLong(sentColumn); + long type = 0; + if (MmsSmsDatabase.MMS_TRANSPORT.equals(msgType)) { + int typeIndex = cursor.getColumnIndex(MESSAGE_BOX); + type = cursor.getLong(typeIndex); + } else if (MmsSmsDatabase.SMS_TRANSPORT.equals(msgType)) { + int typeIndex = cursor.getColumnIndex(SmsDatabase.TYPE); + type = cursor.getLong(typeIndex); + } + + return new Pair<Boolean, Long>(MmsSmsColumns.Types.isOutgoingMessageType(type), sentTime); } 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() { @@ -466,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/PushDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/PushDatabase.java index d1ba25aa7e..b832d04dfc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/PushDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PushDatabase.java @@ -6,7 +6,7 @@ import android.database.Cursor; import androidx.annotation.NonNull; import org.session.libsignal.utilities.Log; -import net.sqlcipher.database.SQLiteDatabase; +import net.zetetic.database.sqlcipher.SQLiteDatabase; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.session.libsignal.utilities.Base64; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt index 74e452db07..87c0b6c182 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt @@ -48,6 +48,14 @@ class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database ) """.trimIndent() + @JvmField + val CREATE_INDEXS = arrayOf( + "CREATE INDEX IF NOT EXISTS reaction_message_id_index ON " + ReactionDatabase.TABLE_NAME + " (" + ReactionDatabase.MESSAGE_ID + ");", + "CREATE INDEX IF NOT EXISTS reaction_is_mms_index ON " + ReactionDatabase.TABLE_NAME + " (" + ReactionDatabase.IS_MMS + ");", + "CREATE INDEX IF NOT EXISTS reaction_message_id_is_mms_index ON " + ReactionDatabase.TABLE_NAME + " (" + ReactionDatabase.MESSAGE_ID + ", " + ReactionDatabase.IS_MMS + ");", + "CREATE INDEX IF NOT EXISTS reaction_sort_id_index ON " + ReactionDatabase.TABLE_NAME + " (" + ReactionDatabase.SORT_ID + ");", + ) + @JvmField val CREATE_REACTION_TRIGGERS = arrayOf( """ 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 58693172ed..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; @@ -11,7 +11,7 @@ import androidx.annotation.Nullable; import com.annimon.stream.Stream; -import net.sqlcipher.database.SQLiteDatabase; +import net.zetetic.database.sqlcipher.SQLiteDatabase; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.MaterialColor; @@ -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"; @@ -62,13 +63,15 @@ public class RecipientDatabase extends Database { private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode"; private static final String FORCE_SMS_SELECTION = "force_sms_selection"; private static final String NOTIFY_TYPE = "notify_type"; // all, mentions only, none + private static final String WRAPPER_HASH = "wrapper_hash"; + private static final String 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, + FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS }; static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION) @@ -120,22 +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; @@ -154,18 +172,14 @@ public class RecipientDatabase extends Database { public Optional<RecipientSettings> getRecipientSettings(@NonNull Address address) { SQLiteDatabase database = databaseHelper.getReadableDatabase(); - Cursor cursor = null; - try { - cursor = database.query(TABLE_NAME, null, ADDRESS + " = ?", new String[] {address.serialize()}, null, null, null); + try (Cursor cursor = database.query(TABLE_NAME, null, ADDRESS + " = ?", new String[]{address.serialize()}, null, null, null)) { if (cursor != null && cursor.moveToNext()) { return getRecipientSettings(cursor); } return Optional.absent(); - } finally { - if (cursor != null) cursor.close(); } } @@ -175,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)); @@ -194,6 +209,8 @@ public class RecipientDatabase extends Database { String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL)); int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE)); boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1; + String wrapperHash = cursor.getString(cursor.getColumnIndexOrThrow(WRAPPER_HASH)); + boolean blocksCommunityMessageRequests = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCKS_COMMUNITY_MESSAGE_REQUESTS)) == 1; MaterialColor color; byte[] profileKey = null; @@ -216,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), @@ -225,7 +243,7 @@ public class RecipientDatabase extends Database { systemPhoneLabel, systemContactUri, signalProfileName, signalProfileAvatar, profileSharing, notificationChannel, Recipient.UnidentifiedAccessMode.fromMode(unidentifiedAccessMode), - forceSmsSelection)); + forceSmsSelection, wrapperHash, blocksCommunityMessageRequests)); } public void setColor(@NonNull Recipient recipient, @NonNull MaterialColor color) { @@ -252,6 +270,24 @@ public class RecipientDatabase extends Database { notifyRecipientListeners(); } + public boolean getApproved(@NonNull Address address) { + SQLiteDatabase db = getReadableDatabase(); + try (Cursor cursor = db.query(TABLE_NAME, new String[]{APPROVED}, ADDRESS + " = ?", new String[]{address.serialize()}, null, null, null)) { + if (cursor != null && cursor.moveToNext()) { + return cursor.getInt(cursor.getColumnIndexOrThrow(APPROVED)) == 1; + } + } + return false; + } + + public void setRecipientHash(@NonNull Recipient recipient, String recipientHash) { + ContentValues values = new ContentValues(); + values.put(WRAPPER_HASH, recipientHash); + updateOrInsert(recipient.getAddress(), values); + recipient.resolve().setWrapperHash(recipientHash); + notifyRecipientListeners(); + } + public void setApproved(@NonNull Recipient recipient, boolean approved) { ContentValues values = new ContentValues(); values.put(APPROVED, approved ? 1 : 0); @@ -268,15 +304,7 @@ public class RecipientDatabase extends Database { notifyRecipientListeners(); } - public void setBlocked(@NonNull Recipient recipient, boolean blocked) { - ContentValues values = new ContentValues(); - values.put(BLOCK, blocked ? 1 : 0); - updateOrInsert(recipient.getAddress(), values); - recipient.resolve().setBlocked(blocked); - notifyRecipientListeners(); - } - - public void setBlocked(@NonNull List<Recipient> recipients, boolean blocked) { + public void setBlocked(@NonNull Iterable<Recipient> recipients, boolean blocked) { SQLiteDatabase db = getWritableDatabase(); db.beginTransaction(); try { @@ -315,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()); @@ -382,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(); @@ -415,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 37efc9a438..106cc86e17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java @@ -1,15 +1,16 @@ package org.thoughtcrime.securesms.database; import android.content.Context; +import android.database.Cursor; import androidx.annotation.NonNull; import com.annimon.stream.Stream; -import net.sqlcipher.Cursor; -import net.sqlcipher.database.SQLiteDatabase; +import net.zetetic.database.sqlcipher.SQLiteDatabase; import org.session.libsession.utilities.Util; + import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import java.util.List; @@ -63,7 +64,7 @@ public class SearchDatabase extends Database { ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " + MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " + "snippet(" + SMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " + - SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " + + SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT + ", " + SMS_FTS_TABLE_NAME + "." + THREAD_ID + " " + "FROM " + SmsDatabase.TABLE_NAME + " " + "INNER JOIN " + SMS_FTS_TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " " + @@ -74,13 +75,13 @@ public class SearchDatabase extends Database { ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " + MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " + "snippet(" + MMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " + - MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " + + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT + ", " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " " + "FROM " + MmsDatabase.TABLE_NAME + " " + "INNER JOIN " + MMS_FTS_TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " + "INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " + "WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? " + - "ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " + + "ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC " + "LIMIT ?"; private static final String MESSAGES_FOR_THREAD_QUERY = @@ -88,7 +89,7 @@ public class SearchDatabase extends Database { ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " + MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " + "snippet(" + SMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " + - SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " + + SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT + ", " + SMS_FTS_TABLE_NAME + "." + THREAD_ID + " " + "FROM " + SmsDatabase.TABLE_NAME + " " + "INNER JOIN " + SMS_FTS_TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " " + @@ -99,13 +100,13 @@ public class SearchDatabase extends Database { ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " + MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " + "snippet(" + MMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " + - MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " + + MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT + ", " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " " + "FROM " + MmsDatabase.TABLE_NAME + " " + "INNER JOIN " + MMS_FTS_TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " + "INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " + "WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? AND " + MmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? " + - "ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " + + "ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC " + "LIMIT 500"; public SearchDatabase(@NonNull Context context, @NonNull SQLCipherOpenHelper databaseHelper) { @@ -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/SessionContactDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt index ef9f0cc383..49a6339368 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -2,10 +2,12 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context +import android.database.Cursor import androidx.core.database.getStringOrNull -import net.sqlcipher.Cursor import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.utilities.SessionId import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper) { @@ -43,6 +45,9 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da val database = databaseHelper.readableDatabase return database.getAll(sessionContactTable, null, null) { cursor -> contactFromCursor(cursor) + }.filter { contact -> + val sessionId = SessionId(contact.sessionID) + sessionId.prefix == IdPrefix.STANDARD }.toSet() } @@ -75,21 +80,6 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da } fun contactFromCursor(cursor: Cursor): Contact { - val sessionID = cursor.getString(sessionID) - val contact = Contact(sessionID) - contact.name = cursor.getStringOrNull(name) - contact.nickname = cursor.getStringOrNull(nickname) - contact.profilePictureURL = cursor.getStringOrNull(profilePictureURL) - contact.profilePictureFileName = cursor.getStringOrNull(profilePictureFileName) - cursor.getStringOrNull(profilePictureEncryptionKey)?.let { - contact.profilePictureEncryptionKey = Base64.decode(it) - } - contact.threadID = cursor.getLong(threadID) - contact.isTrusted = cursor.getInt(isTrusted) != 0 - return contact - } - - fun contactFromCursor(cursor: android.database.Cursor): Contact { val sessionID = cursor.getString(cursor.getColumnIndexOrThrow(sessionID)) val contact = Contact(sessionID) contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name)) 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 595168fdf7..591755b88f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionJobDatabase.kt @@ -2,7 +2,8 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context -import net.sqlcipher.Cursor +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) { @@ -46,7 +50,7 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa databaseHelper.writableDatabase.delete(sessionJobTable, "${Companion.jobID} = ?", arrayOf( jobID )) } - fun getAllPendingJobs(type: String): Map<String, Job?> { + fun getAllJobs(type: String): Map<String, Job?> { val database = databaseHelper.readableDatabase return database.getAll(sessionJobTable, "$jobType = ?", arrayOf( type )) { cursor -> val jobID = cursor.getString(jobID) @@ -83,16 +87,17 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa } } - fun getGroupAvatarDownloadJob(server: String, room: String): GroupAvatarDownloadJob? { + fun getGroupAvatarDownloadJob(server: String, room: String, imageId: String?): GroupAvatarDownloadJob? { val database = databaseHelper.readableDatabase return database.getAll(sessionJobTable, "$jobType = ?", arrayOf(GroupAvatarDownloadJob.KEY)) { jobFromCursor(it) as GroupAvatarDownloadJob? - }.filterNotNull().find { it.server == server && it.room == room } + }.filterNotNull().find { it.server == server && it.room == room && (imageId == null || it.imageId == imageId) } } fun cancelPendingMessageSendJobs(threadID: Long) { val database = databaseHelper.writableDatabase val attachmentUploadJobKeys = mutableListOf<String>() + database.beginTransaction() database.getAll(sessionJobTable, "$jobType = ?", arrayOf( AttachmentUploadJob.KEY )) { cursor -> val job = jobFromCursor(cursor) as AttachmentUploadJob? if (job != null && job.threadID == threadID.toString()) { attachmentUploadJobKeys.add(job.id!!) } @@ -103,15 +108,19 @@ class SessionJobDatabase(context: Context, helper: SQLCipherOpenHelper) : Databa if (job != null && job.message.threadID == threadID) { messageSendJobKeys.add(job.id!!) } } if (attachmentUploadJobKeys.isNotEmpty()) { - val attachmentUploadJobKeysAsString = attachmentUploadJobKeys.joinToString(", ") - database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} IN (?)", - arrayOf( AttachmentUploadJob.KEY, attachmentUploadJobKeysAsString )) + attachmentUploadJobKeys.forEach { + database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} = ?", + arrayOf( AttachmentUploadJob.KEY, it )) + } } if (messageSendJobKeys.isNotEmpty()) { - val messageSendJobKeysAsString = messageSendJobKeys.joinToString(", ") - database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} IN (?)", - arrayOf( MessageSendJob.KEY, messageSendJobKeysAsString )) + messageSendJobKeys.forEach { + database.delete(sessionJobTable, "${Companion.jobType} = ? AND ${Companion.jobID} = ?", + arrayOf( MessageSendJob.KEY, it )) + } } + database.setTransactionSuccessful() + database.endTransaction() } fun isJobCanceled(job: Job): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 67243f73b6..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,19 +22,17 @@ 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.sqlcipher.database.SQLiteDatabase; -import net.sqlcipher.database.SQLiteStatement; - +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; import org.session.libsession.messaging.messages.signal.IncomingTextMessage; import org.session.libsession.messaging.messages.signal.OutgoingTextMessage; +import org.session.libsession.snode.SnodeAPI; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.IdentityKeyMismatchList; @@ -49,9 +47,10 @@ 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; import java.util.Collections; import java.util.LinkedList; import java.util.List; @@ -87,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 + ");", @@ -103,7 +103,7 @@ public class SmsDatabase extends MessagingDatabase { PROTOCOL, READ, STATUS, TYPE, REPLY_PATH_PRESENT, SUBJECT, BODY, SERVICE_CENTER, DELIVERY_RECEIPT_COUNT, MISMATCHED_IDENTITIES, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED, - NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED, + NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED, HAS_MENTION, "json_group_array(json_object(" + "'" + ReactionDatabase.ROW_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.ROW_ID + ", " + "'" + ReactionDatabase.MESSAGE_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + ", " + @@ -121,6 +121,21 @@ public class SmsDatabase extends MessagingDatabase { public static String CREATE_REACTIONS_UNREAD_COMMAND = "ALTER TABLE "+ TABLE_NAME + " " + "ADD COLUMN " + REACTIONS_UNREAD + " INTEGER DEFAULT 0;"; + 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(); @@ -142,7 +157,7 @@ public class SmsDatabase extends MessagingDatabase { long threadId = getThreadIdForMessage(id); - DatabaseComponent.get(context).threadDatabase().update(threadId, false); + DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); notifyConversationListeners(threadId); } @@ -196,6 +211,21 @@ public class SmsDatabase extends MessagingDatabase { updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENDING_TYPE); } + @Override + public void markAsSyncing(long id) { + updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SYNCING_TYPE); + } + + @Override + public void markAsResyncing(long id) { + updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_RESYNCING_TYPE); + } + + @Override + public void markAsSyncFailed(long id) { + updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SYNC_FAILED_TYPE); + } + @Override public void markUnidentified(long id, boolean unidentified) { ContentValues contentValues = new ContentValues(1); @@ -206,22 +236,16 @@ public class SmsDatabase extends MessagingDatabase { } @Override - public void markAsDeleted(long messageId, boolean read) { + public void markAsDeleted(long messageId, boolean read, boolean hasMention) { SQLiteDatabase database = databaseHelper.getWritableDatabase(); ContentValues contentValues = new ContentValues(); contentValues.put(READ, 1); contentValues.put(BODY, ""); + contentValues.put(HAS_MENTION, 0); database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); - long threadId = getThreadIdForMessage(messageId); - if (!read) { DatabaseComponent.get(context).threadDatabase().decrementUnread(threadId, 1); } updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE); } - @Override - public void markExpireStarted(long id) { - markExpireStarted(id, System.currentTimeMillis()); - } - @Override public void markExpireStarted(long id, long startedAtTimestamp) { ContentValues contentValues = new ContentValues(); @@ -232,7 +256,7 @@ public class SmsDatabase extends MessagingDatabase { long threadId = getThreadIdForMessage(id); - DatabaseComponent.get(context).threadDatabase().update(threadId, false); + DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); notifyConversationListeners(threadId); } @@ -295,7 +319,7 @@ public class SmsDatabase extends MessagingDatabase { ID + " = ?", new String[] {String.valueOf(cursor.getLong(cursor.getColumnIndexOrThrow(ID)))}); - DatabaseComponent.get(context).threadDatabase().update(threadId, false); + DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); notifyConversationListeners(threadId); foundMessage = true; } @@ -313,6 +337,9 @@ public class SmsDatabase extends MessagingDatabase { } } + public List<MarkedMessageInfo> setMessagesRead(long threadId, long beforeTime) { + return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1) AND " + DATE_SENT + " <= ?", new String[]{threadId+"", beforeTime+""}); + } public List<MarkedMessageInfo> setMessagesRead(long threadId) { return setMessagesRead(THREAD_ID + " = ? AND (" + READ + " = 0 OR " + REACTIONS_UNREAD + " = 1)", new String[] {String.valueOf(threadId)}); } @@ -331,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(); @@ -376,14 +402,32 @@ public class SmsDatabase extends MessagingDatabase { long threadId = getThreadIdForMessage(messageId); - DatabaseComponent.get(context).threadDatabase().update(threadId, true); + DatabaseComponent.get(context).threadDatabase().update(threadId, true, true); notifyConversationListeners(threadId); notifyConversationListListeners(); return new Pair<>(messageId, threadId); } - protected Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long type, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) { + 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()) { @@ -397,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()); @@ -443,7 +456,9 @@ 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()); if (!TextUtils.isEmpty(message.getPseudoSubject())) values.put(SUBJECT, message.getPseudoSubject()); @@ -461,12 +476,8 @@ public class SmsDatabase extends MessagingDatabase { SQLiteDatabase db = databaseHelper.getWritableDatabase(); long messageId = db.insert(TABLE_NAME, null, values); - if (unread && runIncrement) { - DatabaseComponent.get(context).threadDatabase().incrementUnread(threadId, 1); - } - if (runThreadUpdate) { - DatabaseComponent.get(context).threadDatabase().update(threadId, true); + DatabaseComponent.get(context).threadDatabase().update(threadId, true, true); } if (message.getSubscriptionId() != -1) { @@ -479,16 +490,31 @@ public class SmsDatabase extends MessagingDatabase { } } - public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, boolean runIncrement, boolean runThreadUpdate) { - return insertMessageInbox(message, Types.BASE_INBOX_TYPE, 0, runIncrement, runThreadUpdate); + 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); } public Optional<InsertResult> insertCallMessage(IncomingTextMessage message) { - return insertMessageInbox(message, 0, 0, true, true); + return insertMessageInbox(message, 0, 0, true); } - public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runIncrement, boolean runThreadUpdate) { - return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runIncrement, runThreadUpdate); + public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long serverTimestamp, boolean runThreadUpdate) { + return insertMessageInbox(message, Types.BASE_INBOX_TYPE, serverTimestamp, runThreadUpdate); } public Optional<InsertResult> insertMessageOutbox(long threadId, OutgoingTextMessage message, long serverTimestamp, boolean runThreadUpdate) { @@ -521,12 +547,13 @@ public class SmsDatabase extends MessagingDatabase { contentValues.put(ADDRESS, address.serialize()); contentValues.put(THREAD_ID, threadId); contentValues.put(BODY, message.getMessageBody()); - contentValues.put(DATE_RECEIVED, System.currentTimeMillis()); + contentValues.put(DATE_RECEIVED, SnodeAPI.getNowWithOffset()); contentValues.put(DATE_SENT, message.getSentTimestampMillis()); contentValues.put(READ, 1); 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()); @@ -542,9 +569,12 @@ public class SmsDatabase extends MessagingDatabase { } if (runThreadUpdate) { - DatabaseComponent.get(context).threadDatabase().update(threadId, true); + DatabaseComponent.get(context).threadDatabase().update(threadId, true, true); + } + long lastSeen = DatabaseComponent.get(context).threadDatabase().getLastSeenAndHasSent(threadId).first(); + if (lastSeen < message.getSentTimestampMillis()) { + DatabaseComponent.get(context).threadDatabase().setLastSeen(threadId, message.getSentTimestampMillis()); } - DatabaseComponent.get(context).threadDatabase().setLastSeen(threadId); DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true); @@ -567,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); @@ -581,17 +616,40 @@ 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); + notifyConversationListeners(threadId); + boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, false); + return threadDeleted; + } + + @Override + public boolean deleteMessages(long[] messageIds, long threadId) { + String[] argsArray = new String[messageIds.length]; + String[] argValues = new String[messageIds.length]; + Arrays.fill(argsArray, "?"); + + for (int i = 0; i < messageIds.length; i++) { + argValues[i] = (messageIds[i] + ""); + } + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.delete( + TABLE_NAME, + ID + " IN (" + StringUtils.join(argsArray, ',') + ")", + argValues + ); + boolean threadDeleted = DatabaseComponent.get(context).threadDatabase().update(threadId, false, true); notifyConversationListeners(threadId); return threadDeleted; } @@ -638,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; @@ -656,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 = ""; @@ -664,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(); } @@ -732,15 +790,15 @@ public class SmsDatabase extends MessagingDatabase { public MessageRecord getCurrent() { return new SmsMessageRecord(id, message.getMessageBody(), message.getRecipient(), message.getRecipient(), - System.currentTimeMillis(), System.currentTimeMillis(), + SnodeAPI.getNowWithOffset(), SnodeAPI.getNowWithOffset(), 0, message.isSecureMessage() ? MmsSmsColumns.Types.getOutgoingEncryptedMessageType() : MmsSmsColumns.Types.getOutgoingSmsMessageType(), threadId, 0, new LinkedList<IdentityKeyMismatch>(), message.getExpiresIn(), - System.currentTimeMillis(), 0, false, Collections.emptyList()); + SnodeAPI.getNowWithOffset(), 0, false, Collections.emptyList(), false); } } - public class Reader { + public class Reader implements Closeable { private final Cursor cursor; @@ -777,6 +835,7 @@ public class SmsDatabase extends MessagingDatabase { long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRE_STARTED)); String body = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.BODY)); boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.UNIDENTIFIED)) == 1; + boolean hasMention = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.HAS_MENTION)) == 1; if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { readReceiptCount = 0; @@ -790,7 +849,7 @@ public class SmsDatabase extends MessagingDatabase { recipient, dateSent, dateReceived, deliveryReceiptCount, type, threadId, status, mismatches, - expiresIn, expireStarted, readReceiptCount, unidentified, reactions); + expiresIn, expireStarted, readReceiptCount, unidentified, reactions, hasMention); } private List<IdentityKeyMismatch> getMismatches(String document) { @@ -805,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 76849b8af6..354ec05c46 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -2,16 +2,35 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED +import network.loki.messenger.libsession_util.Contacts +import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.UserGroupsConfig +import network.loki.messenger.libsession_util.UserProfile +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.Conversation +import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.GroupInfo +import network.loki.messenger.libsession_util.util.UserPic +import 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 import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.AttachmentUploadJob +import org.session.libsession.messaging.jobs.BackgroundGroupAddJob +import org.session.libsession.messaging.jobs.ConfigurationSyncJob import org.session.libsession.messaging.jobs.GroupAvatarDownloadJob import org.session.libsession.messaging.jobs.Job import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageSendJob +import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob +import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse @@ -23,44 +42,136 @@ import org.session.libsession.messaging.messages.signal.OutgoingGroupMediaMessag import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage import org.session.libsession.messaging.messages.signal.OutgoingTextMessage import org.session.libsession.messaging.messages.visible.Attachment +import org.session.libsession.messaging.messages.visible.Profile import org.session.libsession.messaging.messages.visible.Reaction import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId 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.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 import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.UpdateMessageData import org.session.libsession.snode.OnionRequestAPI +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.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 import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceGroup +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.KeyHelper +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.guava.Optional -import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.groups.ClosedGroupManager +import org.thoughtcrime.securesms.groups.GroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager -import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.SessionMetaProtocol +import java.security.MessageDigest +import network.loki.messenger.libsession_util.util.Contact as LibSessionContact + +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 + if (!getRecipientApproved(address) && localUserAddress != address.serialize()) return // don't store unapproved / message requests + + val volatile = configFactory.convoVolatile ?: return + if (address.isGroup) { + val groups = configFactory.userGroups ?: return + if (address.isClosedGroup) { + val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize()) + val closedGroup = getGroup(address.toGroupString()) + if (closedGroup != null && closedGroup.isActive) { + val legacyGroup = groups.getOrConstructLegacyGroupInfo(sessionId) + groups.set(legacyGroup) + val newVolatileParams = volatile.getOrConstructLegacyGroup(sessionId).copy( + lastRead = SnodeAPI.nowWithOffset, + ) + volatile.set(newVolatileParams) + } + } else if (address.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") + } + } else if (address.isContact) { + // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config + if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return + // don't update our own address into the contacts DB + if (getUserPublicKey() != address.serialize()) { + val contacts = configFactory.contacts ?: return + contacts.upsertContact(address.serialize()) { + priority = ConfigBase.PRIORITY_VISIBLE + } + } else { + val userProfile = configFactory.user ?: return + userProfile.setNtsPriority(ConfigBase.PRIORITY_VISIBLE) + DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true) + } + val newVolatileParams = volatile.getOrConstructOneToOne(address.serialize()) + volatile.set(newVolatileParams) + } + } + + override fun threadDeleted(address: Address, threadId: Long) { + val volatile = configFactory.convoVolatile ?: return + if (address.isGroup) { + val groups = configFactory.userGroups ?: return + if (address.isClosedGroup) { + val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize()) + volatile.eraseLegacyClosedGroup(sessionId) + groups.eraseLegacyGroup(sessionId) + } else if (address.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") + } + } else { + // non-standard contact prefixes: 15, 00 etc shouldn't be stored in config + if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return + volatile.eraseOneToOne(address.serialize()) + if (getUserPublicKey() != address.serialize()) { + val contacts = configFactory.contacts ?: return + contacts.upsertContact(address.serialize()) { + priority = PRIORITY_HIDDEN + } + } else { + val userProfile = configFactory.user ?: return + userProfile.setNtsPriority(PRIORITY_HIDDEN) + } + } + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } -class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, helper), StorageProtocol { - override fun getUserPublicKey(): String? { return TextSecurePreferences.getLocalNumber(context) } @@ -69,25 +180,40 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return DatabaseComponent.get(context).lokiAPIDatabase().getUserX25519KeyPair() } - override fun getUserDisplayName(): String? { - return TextSecurePreferences.getProfileName(context) + override fun getUserProfile(): Profile { + val displayName = TextSecurePreferences.getProfileName(context) + val profileKey = ProfileKeyUtil.getProfileKey(context) + val profilePictureUrl = TextSecurePreferences.getProfilePictureURL(context) + return Profile(displayName, profileKey, profilePictureUrl) } - override fun getUserProfileKey(): ByteArray? { - return ProfileKeyUtil.getProfileKey(context) + override fun setProfileAvatar(recipient: Recipient, profileAvatar: String?) { + val database = DatabaseComponent.get(context).recipientDatabase() + database.setProfileAvatar(recipient, profileAvatar) } - override fun getUserProfilePictureURL(): String? { - return TextSecurePreferences.getProfilePictureURL(context) + override fun setProfilePicture(recipient: Recipient, newProfilePicture: String?, newProfileKey: ByteArray?) { + val db = DatabaseComponent.get(context).recipientDatabase() + db.setProfileAvatar(recipient, newProfilePicture) + db.setProfileKey(recipient, newProfileKey) } - override fun setUserProfilePictureURL(newValue: String) { + 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) } - TextSecurePreferences.setProfilePictureURL(context, newValue) - RetrieveProfileAvatarJob(ourRecipient, newValue) - ApplicationContext.getInstance(context).jobManager.add(RetrieveProfileAvatarJob(ourRecipient, newValue)) + ourRecipient.resolve().profileKey = newProfileKey + TextSecurePreferences.setProfileKey(context, newProfileKey?.let { Base64.encodeBytes(it) }) + TextSecurePreferences.setProfilePictureURL(context, newProfilePicture) + + if (newProfileKey != null) { + JobQueue.shared.add(RetrieveProfileAvatarJob(newProfilePicture, ourRecipient.address)) + } } override fun getOrGenerateRegistrationID(): Int { @@ -110,19 +236,56 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return database.getAttachmentsForMessage(messageID) } - override fun markConversationAsRead(threadId: Long, updateLastSeen: Boolean) { + override fun getLastSeen(threadId: Long): Long { val threadDb = DatabaseComponent.get(context).threadDatabase() - threadDb.setRead(threadId, updateLastSeen) + return threadDb.getLastSeenAndHasSent(threadId)?.first() ?: 0L } - override fun incrementUnread(threadId: Long, amount: Int) { + override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean) { val threadDb = DatabaseComponent.get(context).threadDatabase() - threadDb.incrementUnread(threadId, amount) + getRecipientForThread(threadId)?.let { recipient -> + val currentLastRead = threadDb.getLastSeenAndHasSent(threadId).first() + // don't set the last read in the volatile if we didn't set it in the DB + if (!threadDb.markAllAsRead(threadId, recipient.isGroupRecipient, lastSeenTime, force) && !force) return + + // don't process configs for inbox recipients + if (recipient.isOpenGroupInboxRecipient) return + + configFactory.convoVolatile?.let { config -> + val convo = when { + // recipient closed group + recipient.isClosedGroupRecipient -> config.getOrConstructLegacyGroup(GroupUtil.doubleDecodeGroupId(recipient.address.serialize())) + // recipient is open group + recipient.isCommunityRecipient -> { + val openGroupJoinUrl = getOpenGroup(threadId)?.joinURL ?: return + BaseCommunityInfo.parseFullUrl(openGroupJoinUrl)?.let { (base, room, pubKey) -> + config.getOrConstructCommunity(base, room, pubKey) + } ?: return + } + // otherwise recipient is one to one + recipient.isContactRecipient -> { + // don't process non-standard session IDs though + val sessionId = SessionId(recipient.address.serialize()) + if (sessionId.prefix != IdPrefix.STANDARD) return + + config.getOrConstructOneToOne(recipient.address.serialize()) + } + else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address.serialize()}") + } + convo.lastRead = lastSeenTime + if (convo.unread) { + convo.unread = lastSeenTime <= currentLastRead + notifyConversationListListeners() + } + config.set(convo) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + } } override fun updateThread(threadId: Long, unarchive: Boolean) { val threadDb = DatabaseComponent.get(context).threadDatabase() - threadDb.update(threadId, unarchive) + threadDb.update(threadId, unarchive, false) } override fun persist(message: VisibleMessage, @@ -131,7 +294,6 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, groupPublicKey: String?, openGroupID: String?, attachments: List<Attachment>, - runIncrement: Boolean, runThreadUpdate: Boolean): Long? { var messageID: Long? = null val senderAddress = fromSerialized(message.sender!!) @@ -158,27 +320,41 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } val targetRecipient = Recipient.from(context, targetAddress, false) if (!targetRecipient.isGroupRecipient) { - val recipientDb = DatabaseComponent.get(context).recipientDatabase() if (isUserSender || isUserBlindedSender) { - recipientDb.setApproved(targetRecipient, true) + setRecipientApproved(targetRecipient, true) } else { - recipientDb.setApprovedMe(targetRecipient, true) + setRecipientApprovedMe(targetRecipient, true) } } + if (message.threadID == null && !targetRecipient.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) - mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID ?: -1, message.receivedTimestamp ?: 0, runIncrement, runThreadUpdate) + val mediaMessage = IncomingMediaMessage.from(message, senderAddress, expiresInMillis, expireStartedAt, group, signalServiceAttachments, quote, linkPreviews) + mmsDatabase.insertSecureDecryptedMessageInbox(mediaMessage, message.threadID!!, message.receivedTimestamp ?: 0, runThreadUpdate) } if (insertResult.isPresent) { messageID = insertResult.get().messageId @@ -188,14 +364,14 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, 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, runIncrement, runThreadUpdate) + smsDatabase.insertMessageInbox(encrypted, message.receivedTimestamp ?: 0, runThreadUpdate) } insertResult.orNull()?.let { result -> messageID = result.messageId @@ -203,7 +379,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } 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 @@ -222,7 +398,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } override fun getAllPendingJobs(type: String): Map<String, Job?> { - return DatabaseComponent.get(context).sessionJobDatabase().getAllPendingJobs(type) + return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(type) } override fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? { @@ -237,8 +413,14 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return DatabaseComponent.get(context).sessionJobDatabase().getMessageReceiveJob(messageReceiveJobID) } - override fun getGroupAvatarDownloadJob(server: String, room: String): GroupAvatarDownloadJob? { - return DatabaseComponent.get(context).sessionJobDatabase().getGroupAvatarDownloadJob(server, room) + override fun getGroupAvatarDownloadJob(server: String, room: String, imageId: String?): GroupAvatarDownloadJob? { + return DatabaseComponent.get(context).sessionJobDatabase().getGroupAvatarDownloadJob(server, room, imageId) + } + + override fun getConfigSyncJob(destination: Destination): Job? { + return DatabaseComponent.get(context).sessionJobDatabase().getAllJobs(ConfigurationSyncJob.KEY).values.firstOrNull { + (it as? ConfigurationSyncJob)?.destination == destination + } } override fun resumeMessageSendJobIfNeeded(messageSendJobID: String) { @@ -250,11 +432,217 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return DatabaseComponent.get(context).sessionJobDatabase().isJobCanceled(job) } + override fun cancelPendingMessageSendJobs(threadID: Long) { + val jobDb = DatabaseComponent.get(context).sessionJobDatabase() + jobDb.cancelPendingMessageSendJobs(threadID) + } + override fun getAuthToken(room: String, server: String): String? { val id = "$server.$room" return DatabaseComponent.get(context).lokiAPIDatabase().getAuthToken(id) } + override fun notifyConfigUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) { + notifyUpdates(forConfigObject, messageTimestamp) + } + + override fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean { + return configFactory.conversationInConfig(publicKey, groupPublicKey, openGroupId, visibleOnly) + } + + override fun canPerformConfigChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean { + return configFactory.canPerformChange(variant, publicKey, changeTimestampMs) + } + + override fun isCheckingCommunityRequests(): Boolean { + return configFactory.user?.getCommunityMessageRequests() == true + } + + private fun notifyUpdates(forConfigObject: ConfigBase, messageTimestamp: Long) { + when (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, 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) + // update name + val name = userProfile.getName() ?: return + val userPic = userProfile.getPic() + val profileManager = SSKEnvironment.shared.profileManager + if (name.isNotEmpty()) { + TextSecurePreferences.setProfileName(context, name) + profileManager.setName(context, recipient, name) + } + + // update pfp + if (userPic == UserPic.DEFAULT) { + clearUserPic() + } else if (userPic.key.isNotEmpty() && userPic.url.isNotEmpty() + && TextSecurePreferences.getProfilePictureURL(context) != userPic.url) { + setUserProfilePicture(userPic.url, userPic.key) + } + if (userProfile.getNtsPriority() == PRIORITY_HIDDEN) { + // delete nts thread if needed + val ourThread = getThreadId(recipient) ?: return + deleteConversation(ourThread) + } else { + // create note to self thread if needed (?) + val 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, messageTimestamp: Long) { + val extracted = contacts.all().toList() + addLibSessionContacts(extracted, messageTimestamp) + } + + override fun clearUserPic() { + val userPublicKey = getUserPublicKey() ?: return + val recipientDatabase = DatabaseComponent.get(context).recipientDatabase() + // would love to get rid of recipient and context from this + val recipient = Recipient.from(context, fromSerialized(userPublicKey), false) + // clear picture if userPic is null + TextSecurePreferences.setProfileKey(context, null) + ProfileKeyUtil.setEncodedProfileKey(context, null) + recipientDatabase.setProfileAvatar(recipient, null) + TextSecurePreferences.setProfileAvatarId(context, 0) + TextSecurePreferences.setProfilePictureURL(context, null) + + Recipient.removeCached(fromSerialized(userPublicKey)) + configFactory.user?.setPic(UserPic.DEFAULT) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + + private fun updateConvoVolatile(convos: ConversationVolatileConfig, messageTimestamp: Long) { + val extracted = convos.all() + for (conversation in extracted) { + val threadId = when (conversation) { + is Conversation.OneToOne -> getThreadIdFor(conversation.sessionId, null, null, createThread = false) + is Conversation.LegacyGroup -> getThreadIdFor("", conversation.groupId,null, createThread = false) + is Conversation.Community -> getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false) + } + if (threadId != null) { + if (conversation.lastRead > getLastSeen(threadId)) { + markConversationAsRead(threadId, conversation.lastRead, force = true) + } + updateThread(threadId, false) + } + } + } + + private fun updateUserGroups(userGroups: UserGroupsConfig, messageTimestamp: Long) { + val threadDb = DatabaseComponent.get(context).threadDatabase() + val localUserPublicKey = getUserPublicKey() ?: return Log.w( + "Loki", + "No user public key when trying to update user groups from config" + ) + val communities = userGroups.allCommunityInfo() + val lgc = userGroups.allLegacyGroupInfo() + val allOpenGroups = getAllOpenGroups() + val toDeleteCommunities = allOpenGroups.filter { + Conversation.Community(BaseCommunityInfo(it.value.server, it.value.room, it.value.publicKey), 0, false).baseCommunityInfo.fullUrl() !in communities.map { it.community.fullUrl() } + } + + val existingCommunities: Map<Long, OpenGroup> = allOpenGroups.filterKeys { it !in toDeleteCommunities.keys } + val toAddCommunities = communities.filter { it.community.fullUrl() !in existingCommunities.map { it.value.joinURL } } + val existingJoinUrls = existingCommunities.values.map { it.joinURL } + + val existingClosedGroups = getAllGroups(includeInactive = true).filter { it.isClosedGroup } + val lgcIds = lgc.map { it.sessionId } + val toDeleteClosedGroups = existingClosedGroups.filter { group -> + GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds + } + + // delete the ones which are not listed in the config + toDeleteCommunities.values.forEach { openGroup -> + OpenGroupManager.delete(openGroup.server, openGroup.room, context) + } + + toDeleteClosedGroups.forEach { deleteGroup -> + val threadId = getThreadId(deleteGroup.encodedId) + if (threadId != null) { + ClosedGroupManager.silentlyRemoveGroup(context,threadId,GroupUtil.doubleDecodeGroupId(deleteGroup.encodedId), deleteGroup.encodedId, localUserPublicKey, delete = true) + } + } + + toAddCommunities.forEach { toAddCommunity -> + val joinUrl = toAddCommunity.community.fullUrl() + if (!hasBackgroundGroupAddJob(joinUrl)) { + JobQueue.shared.add(BackgroundGroupAddJob(joinUrl)) + } + } + + for (groupInfo in communities) { + val groupBaseCommunity = groupInfo.community + if (groupBaseCommunity.fullUrl() in existingJoinUrls) { + // add it + val (threadId, _) = existingCommunities.entries.first { (_, v) -> v.joinURL == groupInfo.community.fullUrl() } + threadDb.setPinned(threadId, groupInfo.priority == PRIORITY_PINNED) + } + } + + for (group in lgc) { + val 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) { + if (group.priority == PRIORITY_HIDDEN && existingThread != null) { + ClosedGroupManager.silentlyRemoveGroup(context,existingThread,GroupUtil.doubleDecodeGroupId(existingGroup.encodedId), existingGroup.encodedId, localUserPublicKey, delete = true) + } else if (existingThread == null) { + Log.w("Loki-DBG", "Existing group had no thread to hide") + } else { + Log.d("Loki-DBG", "Setting existing group pinned status to ${group.priority}") + threadDb.setPinned(existingThread, group.priority == PRIORITY_PINNED) + } + } else { + val members = group.members.keys.map { Address.fromSerialized(it) } + val admins = group.members.filter { it.value /*admin = true*/ }.keys.map { Address.fromSerialized(it) } + val title = group.name + val formationTimestamp = (group.joinedAt * 1000L) + createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp) + setProfileSharing(Address.fromSerialized(groupId), true) + // Add the group to the user's set of public keys to poll for + addClosedGroupPublicKey(group.sessionId) + // Store the encryption key pair + val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey)) + addClosedGroupEncryptionKeyPair(keyPair, group.sessionId, SnodeAPI.nowWithOffset) + // Notify the PN server + PushRegistryV1.subscribeGroup(group.sessionId, publicKey = localUserPublicKey) + // Notify the user + val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId)) + threadDb.setDate(threadID, formationTimestamp) + insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp) + // Don't create config group here, it's from a config update + // Start polling + ClosedGroupPollerV2.shared.startPolling(group.sessionId) + } + getThreadId(Address.fromSerialized(groupId))?.let { + setExpirationConfiguration( + getExpirationConfiguration(it)?.takeIf { it.updatedTimestampMs > messageTimestamp } + ?: ExpirationConfiguration(it, afterSend(group.disappearingTimer), messageTimestamp) + ) + } + } + } + override fun setAuthToken(room: String, server: String, newValue: String) { val id = "$server.$room" DatabaseComponent.get(context).lokiAPIDatabase().setAuthToken(id, newValue) @@ -335,6 +723,14 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseComponent.get(context).groupDatabase().updateProfilePicture(groupID, newValue) } + override fun removeProfilePicture(groupID: String) { + DatabaseComponent.get(context).groupDatabase().removeProfilePicture(groupID) + } + + override fun hasDownloadedProfilePicture(groupID: String): Boolean { + return DatabaseComponent.get(context).groupDatabase().hasDownloadedProfilePicture(groupID) + } + override fun getReceivedMessageTimestamps(): Set<Long> { return SessionMetaProtocol.getTimestamps() } @@ -347,10 +743,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, 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( @@ -370,14 +766,53 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, override fun markAsSent(timestamp: Long, author: String) { val database = DatabaseComponent.get(context).mmsSmsDatabase() - val messageRecord = database.getMessageFor(timestamp, author) ?: return - if (messageRecord.isMms) { - val mmsDatabase = DatabaseComponent.get(context).mmsDatabase() - mmsDatabase.markAsSent(messageRecord.getId(), true) - } else { - val smsDatabase = DatabaseComponent.get(context).smsDatabase() - smsDatabase.markAsSent(messageRecord.getId(), true) + 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) { + DatabaseComponent.get(context).mmsDatabase().markAsSent(messageRecord.getId(), true) + } else { + 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) + } + } + + override fun markAsSyncing(timestamp: Long, author: String) { + DatabaseComponent.get(context).mmsSmsDatabase() + .getMessageFor(timestamp, author) + ?.run { getMmsDatabaseElseSms(isMms).markAsSyncing(id) } + } + + private fun getMmsDatabaseElseSms(isMms: Boolean) = + if (isMms) DatabaseComponent.get(context).mmsDatabase() + else DatabaseComponent.get(context).smsDatabase() + + override fun markAsResyncing(timestamp: Long, author: String) { + DatabaseComponent.get(context).mmsSmsDatabase() + .getMessageFor(timestamp, author) + ?.run { getMmsDatabaseElseSms(isMms).markAsResyncing(id) } } override fun markAsSending(timestamp: Long, author: String) { @@ -395,7 +830,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, 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) @@ -405,7 +844,27 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } } - override fun setErrorMessage(timestamp: Long, author: String, error: Exception) { + // 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 if (messageRecord.isMms) { @@ -428,8 +887,33 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } } - override fun setMessageServerHash(messageID: Long, serverHash: String) { - DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(messageID, serverHash) + override fun markAsSyncFailed(timestamp: Long, author: String, error: Exception) { + val database = DatabaseComponent.get(context).mmsSmsDatabase() + val messageRecord = database.getMessageFor(timestamp, author) ?: return + + database.getMessageFor(timestamp, author) + ?.run { getMmsDatabaseElseSms(isMms).markAsSyncFailed(id) } + + if (error.localizedMessage != null) { + val message: String + if (error is OnionRequestAPI.HTTPRequestFailedAtDestinationException && error.statusCode == 429) { + message = "429: Rate limited." + } else { + message = error.localizedMessage!! + } + DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), message) + } else { + DatabaseComponent.get(context).lokiMessageDatabase().setErrorMessage(messageRecord.getId(), error.javaClass.simpleName) + } + } + + override fun clearErrorMessage(messageID: Long) { + val db = DatabaseComponent.get(context).lokiMessageDatabase() + db.clearErrorMessage(messageID) + } + + override fun setMessageServerHash(messageID: Long, mms: Boolean, serverHash: String) { + DatabaseComponent.get(context).lokiMessageDatabase().setMessageServerHash(messageID, mms, serverHash) } override fun getGroup(groupID: String): GroupRecord? { @@ -441,6 +925,58 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseComponent.get(context).groupDatabase().create(groupId, title, members, avatar, relay, admins, formationTimestamp) } + override fun createInitialConfigGroup(groupPublicKey: String, name: String, members: Map<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) + val groupInfo = GroupInfo.LegacyGroupInfo( + sessionId = groupPublicKey, + name = name, + members = members, + priority = ConfigBase.PRIORITY_VISIBLE, + encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte + encSecKey = encryptionKeyPair.privateKey.serialize(), + disappearingTimer = expirationTimer.toLong(), + joinedAt = (formationTimestamp / 1000L) + ) + // shouldn't exist, don't use getOrConstruct + copy + userGroups.set(groupInfo) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + + override fun updateGroupConfig(groupPublicKey: String) { + val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) + val groupAddress = fromSerialized(groupID) + val existingGroup = getGroup(groupID) + ?: return Log.w("Loki-DBG", "No existing group for ${groupPublicKey.take(4)}} when updating group config") + val userGroups = configFactory.userGroups ?: return + if (!existingGroup.isActive) { + userGroups.eraseLegacyGroup(groupPublicKey) + return + } + val name = existingGroup.title + val admins = existingGroup.admins.map { it.serialize() } + val members = existingGroup.members.map { it.serialize() } + val membersMap = GroupUtil.createConfigMemberMap(admins = admins, members = members) + val latestKeyPair = getLatestClosedGroupEncryptionKeyPair(groupPublicKey) + ?: return Log.w("Loki-DBG", "No latest closed group encryption key pair for ${groupPublicKey.take(4)}} when updating group config") + + val threadID = getThreadId(groupAddress) ?: return + val groupInfo = userGroups.getOrConstructLegacyGroupInfo(groupPublicKey).copy( + name = name, + members = membersMap, + encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte + encSecKey = latestKeyPair.privateKey.serialize(), + priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, + disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L, + joinedAt = (existingGroup.formationTimestamp / 1000L) + ) + userGroups.set(groupInfo) + } + override fun isGroupActive(groupPublicKey: String): Boolean { return DatabaseComponent.get(context).groupDatabase().getGroup(GroupUtil.doubleEncodeGroupID(groupPublicKey)).orNull()?.isActive == true } @@ -467,22 +1003,24 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, 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) + 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() - smsDB.insertMessageInbox(infoMessage, true, true) + smsDB.insertMessageInbox(infoMessage, true) } 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) } @@ -519,8 +1057,8 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseComponent.get(context).lokiAPIDatabase().removeClosedGroupPublicKey(groupPublicKey) } - override fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) { - DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey) + override fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) { + DatabaseComponent.get(context).lokiAPIDatabase().addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, timestamp) } override fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) { @@ -537,11 +1075,6 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, .updateTimestampUpdated(groupID, updatedTimestamp) } - override fun setExpirationTimer(groupID: String, duration: Int) { - val recipient = Recipient.from(context, fromSerialized(groupID), false) - DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); - } - override fun setServerCapabilities(server: String, capabilities: List<String>) { return DatabaseComponent.get(context).lokiAPIDatabase().setServerCapabilities(server, capabilities) } @@ -558,16 +1091,29 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, OpenGroupManager.updateOpenGroup(openGroup, context) } - override fun getAllGroups(): List<GroupRecord> { - return DatabaseComponent.get(context).groupDatabase().allGroups + override fun getAllGroups(includeInactive: Boolean): List<GroupRecord> { + return DatabaseComponent.get(context).groupDatabase().getAllGroups(includeInactive) } - override fun addOpenGroup(urlAsString: String) { - OpenGroupManager.addOpenGroup(urlAsString, context) + override fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? { + return OpenGroupManager.addOpenGroup(urlAsString, context) } - override fun onOpenGroupAdded(server: String) { + override fun onOpenGroupAdded(server: String, room: String) { OpenGroupManager.restartPollerForServer(server.removeSuffix("/")) + val groups = configFactory.userGroups ?: return + val volatileConfig = configFactory.convoVolatile ?: return + val openGroup = getOpenGroup(room, server) ?: return + val (infoServer, infoRoom, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return + val pubKeyHex = Hex.toStringCondensed(pubKey) + val communityInfo = groups.getOrConstructCommunityInfo(infoServer, infoRoom, pubKeyHex) + groups.set(communityInfo) + val volatile = volatileConfig.getOrConstructCommunity(infoServer, infoRoom, pubKey) + if (volatile.lastRead != 0L) { + val threadId = getThreadId(openGroup) ?: return + markConversationAsRead(threadId, volatile.lastRead, force = true) + } + volatileConfig.set(volatile) } override fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean { @@ -585,17 +1131,19 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) } - override fun getOrCreateThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?): Long { + override fun getThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?, createThread: Boolean): Long? { val database = DatabaseComponent.get(context).threadDatabase() return if (!openGroupID.isNullOrEmpty()) { val recipient = Recipient.from(context, fromSerialized(GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray())), false) - database.getThreadIdIfExistsFor(recipient) + database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it } } else if (!groupPublicKey.isNullOrEmpty()) { val recipient = Recipient.from(context, fromSerialized(GroupUtil.doubleEncodeGroupID(groupPublicKey)), false) - database.getOrCreateThreadIdFor(recipient) + if (createThread) database.getOrCreateThreadIdFor(recipient) + else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it } } else { val recipient = Recipient.from(context, fromSerialized(publicKey), false) - database.getOrCreateThreadIdFor(recipient) + if (createThread) database.getOrCreateThreadIdFor(recipient) + else database.getThreadIdIfExistsFor(recipient).let { if (it == -1L) null else it } } } @@ -604,6 +1152,10 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return getThreadId(address) } + override fun getThreadId(openGroup: OpenGroup): Long? { + return GroupManager.getOpenGroupThreadID("${openGroup.server.removeSuffix("/")}.${openGroup.room}", context) + } + override fun getThreadId(address: Address): Long? { val recipient = Recipient.from(context, address, false) return getThreadId(recipient) @@ -633,6 +1185,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, override fun setContact(contact: Contact) { DatabaseComponent.get(context).sessionContactDatabase().setContact(contact) + val address = fromSerialized(contact.sessionID) + if (!getRecipientApproved(address)) return + val recipientHash = SSKEnvironment.shared.profileManager.contactUpdatedInternal(contact) + val recipient = Recipient.from(context, address, false) + setRecipientHash(recipient, recipientHash) } override fun getRecipientForThread(threadId: Long): Recipient? { @@ -640,8 +1197,58 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } 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>, timestamp: Long) { + val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() + val moreContacts = contacts.filter { contact -> + val id = SessionId(contact.id) + id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.sessionId != null } + } + val profileManager = SSKEnvironment.shared.profileManager + moreContacts.forEach { contact -> + val address = fromSerialized(contact.id) + val recipient = Recipient.from(context, address, false) + setBlocked(listOf(recipient), contact.blocked, fromConfigUpdate = true) + setRecipientApproved(recipient, contact.approved) + setRecipientApprovedMe(recipient, contact.approvedMe) + if (contact.name.isNotEmpty()) { + profileManager.setName(context, recipient, contact.name) + } else { + profileManager.setName(context, recipient, null) + } + if (contact.nickname.isNotEmpty()) { + profileManager.setNickname(context, recipient, contact.nickname) + } else { + profileManager.setNickname(context, recipient, null) + } + + if (contact.profilePicture != UserPic.DEFAULT) { + val (url, key) = contact.profilePicture + if (key.size != ProfileKeyUtil.PROFILE_KEY_BYTES) return@forEach + profileManager.setProfilePicture(context, recipient, url, key) + profileManager.setUnidentifiedAccessMode(context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN) + } else { + profileManager.setProfilePicture(context, recipient, null, null) + } + if (contact.priority == PRIORITY_HIDDEN) { + getThreadId(fromSerialized(contact.id))?.let(::deleteConversation) + } else { + ( + 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()) + } } override fun addContacts(contacts: List<ConfigurationMessage.Contact>) { @@ -667,17 +1274,18 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, recipientDatabase.setProfileSharing(recipient, true) recipientDatabase.setRegistered(recipient, Recipient.RegisteredState.REGISTERED) // create Thread if needed - val threadId = threadDatabase.getOrCreateThreadIdFor(recipient) + val threadId = threadDatabase.getThreadIdIfExistsFor(recipient) if (contact.didApproveMe == true) { recipientDatabase.setApprovedMe(recipient, true) } - if (contact.isApproved == true) { - recipientDatabase.setApproved(recipient, true) + if (contact.isApproved == true && threadId != -1L) { + setRecipientApproved(recipient, true) threadDatabase.setHasSent(threadId, true) } - if (contact.isBlocked == true) { - recipientDatabase.setBlocked(recipient, true) - threadDatabase.deleteConversation(threadId) + + val contactIsBlocked: Boolean? = contact.isBlocked + if (contactIsBlocked != null && recipient.isBlocked != contactIsBlocked) { + setBlocked(listOf(recipient), contactIsBlocked, fromConfigUpdate = true) } } if (contacts.isNotEmpty()) { @@ -685,6 +1293,11 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } } + override fun setRecipientHash(recipient: Recipient, recipientHash: String?) { + val recipientDb = DatabaseComponent.get(context).recipientDatabase() + recipientDb.setRecipientHash(recipient, recipientHash) + } + override fun getLastUpdated(threadID: Long): Long { val threadDB = DatabaseComponent.get(context).threadDatabase() return threadDB.getLastUpdated(threadID) @@ -705,7 +1318,84 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, return mmsSmsDb.getConversationCount(threadID) } + override fun setPinned(threadID: Long, isPinned: Boolean) { + val threadDB = DatabaseComponent.get(context).threadDatabase() + threadDB.setPinned(threadID, isPinned) + val threadRecipient = getRecipientForThread(threadID) ?: return + if (threadRecipient.isLocalNumber) { + val user = configFactory.user ?: return + user.setNtsPriority(if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE) + } else if (threadRecipient.isContactRecipient) { + val contacts = configFactory.contacts ?: return + contacts.upsertContact(threadRecipient.address.serialize()) { + priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE + } + } else if (threadRecipient.isGroupRecipient) { + val groups = configFactory.userGroups ?: return + if (threadRecipient.isClosedGroupRecipient) { + val sessionId = GroupUtil.doubleDecodeGroupId(threadRecipient.address.serialize()) + val newGroupInfo = groups.getOrConstructLegacyGroupInfo(sessionId).copy ( + priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE + ) + groups.set(newGroupInfo) + } else if (threadRecipient.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 ( + priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE + ) + groups.set(newGroupInfo) + } + } + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + override fun isPinned(threadID: Long): Boolean { + val threadDB = DatabaseComponent.get(context).threadDatabase() + return threadDB.isPinned(threadID) + } + + override fun setThreadDate(threadId: Long, newDate: Long) { + val threadDb = DatabaseComponent.get(context).threadDatabase() + threadDb.setDate(threadId, newDate) + } + + override fun 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 threadDB = DatabaseComponent.get(context).threadDatabase() + val groupDB = DatabaseComponent.get(context).groupDatabase() + threadDB.deleteConversation(threadID) + + 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)}") + } + } override fun getAttachmentDataUri(attachmentId: AttachmentId): Uri { return PartAuthority.getAttachmentDataUri(attachmentId) @@ -721,12 +1411,18 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, 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, false, @@ -739,14 +1435,23 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, Optional.of(message) ) - database.insertSecureDecryptedMessageInbox(mediaMessage, -1, runIncrement = true, runThreadUpdate = true) + database.insertSecureDecryptedMessageInbox(mediaMessage, threadId, runThreadUpdate = true) + + SSKEnvironment.shared.messageExpirationManager.maybeStartExpiration(sentTimestamp, senderPublicKey, expiryMode) } override fun insertMessageRequestResponse(response: MessageRequestResponse) { val userPublicKey = getUserPublicKey() val senderPublicKey = response.sender!! val recipientPublicKey = response.recipient!! - if (userPublicKey == null || (userPublicKey != recipientPublicKey && userPublicKey != senderPublicKey)) return + + if ( + userPublicKey == null + || (userPublicKey != recipientPublicKey && userPublicKey != senderPublicKey) + // this is true if it is a sync message + || (userPublicKey == recipientPublicKey && userPublicKey == senderPublicKey) + ) return + val recipientDb = DatabaseComponent.get(context).recipientDatabase() val threadDB = DatabaseComponent.get(context).threadDatabase() if (userPublicKey == senderPublicKey) { @@ -758,7 +1463,25 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val mmsDb = DatabaseComponent.get(context).mmsDatabase() val smsDb = DatabaseComponent.get(context).smsDatabase() val sender = Recipient.from(context, fromSerialized(senderPublicKey), false) - val threadId = threadDB.getOrCreateThreadIdFor(sender) + val threadId = getOrCreateThreadIdFor(sender.address) + val profile = response.profile + if (profile != null) { + val profileManager = SSKEnvironment.shared.profileManager + val name = profile.displayName!! + if (name.isNotEmpty()) { + profileManager.setName(context, sender, name) + } + val newProfileKey = profile.profileKey + + val needsProfilePicture = !AvatarHelper.avatarFileExists(context, sender.address) + val profileKeyValid = newProfileKey?.isNotEmpty() == true && (newProfileKey.size == 16 || newProfileKey.size == 32) && profile.profilePictureURL?.isNotEmpty() == true + val profileKeyChanged = (sender.profileKey == null || !MessageDigest.isEqual(sender.profileKey, newProfileKey)) + + if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) { + profileManager.setProfilePicture(context, sender, profile.profilePictureURL!!, newProfileKey!!) + profileManager.setUnidentifiedAccessMode(context, sender, Recipient.UnidentifiedAccessMode.UNKNOWN) + } + } threadDB.setHasSent(threadId, true) val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase() val mappings = mutableMapOf<String, BlindedIdMapping>() @@ -769,7 +1492,7 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, val blindedId = when { recipient.isGroupRecipient -> null recipient.isOpenGroupInboxRecipient -> { - GroupUtil.getDecodedOpenGroupInbox(address) + GroupUtil.getDecodedOpenGroupInboxSessionId(address) } else -> { if (SessionId(address).prefix == IdPrefix.BLINDED) { @@ -795,15 +1518,16 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, } recipientDb.setApproved(sender, true) recipientDb.setApprovedMe(sender, true) - val message = IncomingMediaMessage( sender.address, response.sentTimestamp!!, -1, 0, + 0, false, false, true, + false, Optional.absent(), Optional.absent(), Optional.absent(), @@ -812,23 +1536,42 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, Optional.absent(), Optional.absent() ) - mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runIncrement = true, runThreadUpdate = true) + mmsDb.insertSecureDecryptedMessageInbox(message, threadId, runThreadUpdate = true) } } + override fun getRecipientApproved(address: Address): Boolean { + return DatabaseComponent.get(context).recipientDatabase().getApproved(address) + } + override fun setRecipientApproved(recipient: Recipient, approved: Boolean) { DatabaseComponent.get(context).recipientDatabase().setApproved(recipient, approved) + if (recipient.isLocalNumber || !recipient.isContactRecipient) return + configFactory.contacts?.upsertContact(recipient.address.serialize()) { + this.approved = approved + } } override fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean) { DatabaseComponent.get(context).recipientDatabase().setApprovedMe(recipient, approvedMe) + if (recipient.isLocalNumber || !recipient.isContactRecipient) return + configFactory.contacts?.upsertContact(recipient.address.serialize()) { + this.approvedMe = approvedMe + } } override fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) { 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 { @@ -875,16 +1618,12 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, 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 { @@ -951,9 +1690,18 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms)) } - override fun unblock(toUnblock: List<Recipient>) { + override fun setBlocked(recipients: Iterable<Recipient>, isBlocked: Boolean, fromConfigUpdate: Boolean) { val recipientDb = DatabaseComponent.get(context).recipientDatabase() - recipientDb.setBlocked(toUnblock, false) + recipientDb.setBlocked(recipients, isBlocked) + recipients.filter { it.isContactRecipient && !it.isLocalNumber }.forEach { recipient -> + configFactory.contacts?.upsertContact(recipient.address.serialize()) { + this.blocked = isBlocked + } + } + val contactsConfig = configFactory.contacts ?: return + if (contactsConfig.needsPush() && !fromConfigUpdate) { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } } override fun blockedContacts(): List<Recipient> { @@ -961,4 +1709,99 @@ class Storage(context: Context, helper: SQLCipherOpenHelper) : Database(context, 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 02a82de1d4..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,15 +26,12 @@ 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.sqlcipher.database.SQLiteDatabase; - +import net.zetetic.database.sqlcipher.SQLiteDatabase; import org.jetbrains.annotations.NotNull; +import org.session.libsession.snode.SnodeAPI; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Contact; import org.session.libsession.utilities.DelimiterUtil; @@ -49,22 +46,18 @@ 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; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.groups.OpenGroupMigrator; 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.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; @@ -74,20 +67,26 @@ import java.util.Set; public class ThreadDatabase extends Database { + public interface ConversationThreadUpdateListener { + void threadCreated(@NonNull Address address, long threadId); + void threadDeleted(@NonNull Address address, long threadId); + } + private static final String TAG = ThreadDatabase.class.getSimpleName(); private final Map<Long, Address> addressCache = new HashMap<>(); 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"; private static final String SNIPPET_CHARSET = "snippet_cs"; public static final String READ = "read"; public static final String UNREAD_COUNT = "unread_count"; - public static final String TYPE = "type"; + public static final String UNREAD_MENTION_COUNT = "unread_mention_count"; + 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"; @@ -97,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, 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 }; @@ -127,27 +126,37 @@ 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 + " " + "ADD COLUMN " + IS_PINNED + " INTEGER DEFAULT 0;"; } + public static String getUnreadMentionCountCommand() { + return "ALTER TABLE "+ TABLE_NAME + " " + + "ADD COLUMN " + UNREAD_MENTION_COUNT + " INTEGER DEFAULT 0;"; + } + + private ConversationThreadUpdateListener updateListener; + public ThreadDatabase(Context context, SQLCipherOpenHelper databaseHelper) { super(context, databaseHelper); } + public void setUpdateListener(ConversationThreadUpdateListener updateListener) { + this.updateListener = updateListener; + } + private long createThreadForRecipient(Address address, boolean group, int distributionType) { ContentValues contentValues = new ContentValues(4); - long date = System.currentTimeMillis(); + 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); @@ -160,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); @@ -172,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 + ""}); @@ -184,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); } @@ -201,19 +208,21 @@ public class ThreadDatabase extends Database { } private void deleteThread(long threadId) { + Recipient recipient = getRecipientForThreadId(threadId); SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.delete(TABLE_NAME, ID_WHERE, new String[] {threadId + ""}); + int numberRemoved = db.delete(TABLE_NAME, ID_WHERE, new String[] {threadId + ""}); addressCache.remove(threadId); notifyConversationListListeners(); + if (updateListener != null && numberRemoved > 0 && recipient != null) { + updateListener.threadDeleted(recipient.getAddress(), threadId); + } } private void deleteThreads(Set<Long> threadIds) { 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); @@ -272,7 +281,7 @@ public class ThreadDatabase extends Database { DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, lastTweetDate); - update(threadId, false); + update(threadId, false, true); notifyConversationListeners(threadId); } } finally { @@ -285,17 +294,42 @@ public class ThreadDatabase extends Database { Log.i("ThreadDatabase", "Trimming thread: " + threadId + " before :"+timestamp); DatabaseComponent.get(context).smsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); DatabaseComponent.get(context).mmsDatabase().deleteMessagesInThreadBeforeDate(threadId, timestamp); - update(threadId, false); + update(threadId, false, true); notifyConversationListeners(threadId); } + public List<MarkedMessageInfo> setRead(long threadId, long lastReadTime) { + + final List<MarkedMessageInfo> smsRecords = DatabaseComponent.get(context).smsDatabase().setMessagesRead(threadId, lastReadTime); + final List<MarkedMessageInfo> mmsRecords = DatabaseComponent.get(context).mmsDatabase().setMessagesRead(threadId, lastReadTime); + + if (smsRecords.isEmpty() && mmsRecords.isEmpty()) { + return Collections.emptyList(); + } + + ContentValues contentValues = new ContentValues(2); + contentValues.put(READ, smsRecords.isEmpty() && mmsRecords.isEmpty()); + contentValues.put(LAST_SEEN, lastReadTime); + + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""}); + + notifyConversationListListeners(); + + return new LinkedList<MarkedMessageInfo>() {{ + addAll(smsRecords); + addAll(mmsRecords); + }}; + } + public List<MarkedMessageInfo> setRead(long threadId, boolean lastSeen) { ContentValues contentValues = new ContentValues(1); contentValues.put(READ, 1); contentValues.put(UNREAD_COUNT, 0); + contentValues.put(UNREAD_MENTION_COUNT, 0); if (lastSeen) { - contentValues.put(LAST_SEEN, System.currentTimeMillis()); + contentValues.put(LAST_SEEN, SnodeAPI.getNowWithOffset()); } SQLiteDatabase db = databaseHelper.getWritableDatabase(); @@ -312,38 +346,30 @@ public class ThreadDatabase extends Database { }}; } - public void incrementUnread(long threadId, int amount) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = 0, " + - UNREAD_COUNT + " = " + UNREAD_COUNT + " + ? WHERE " + ID + " = ?", - new String[] {String.valueOf(amount), - String.valueOf(threadId)}); - } - - public void decrementUnread(long threadId, int amount) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = 0, " + - UNREAD_COUNT + " = " + UNREAD_COUNT + " - ? WHERE " + ID + " = ? AND " + UNREAD_COUNT + " > 0", - new String[] {String.valueOf(amount), - String.valueOf(threadId)}); - } - 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 + ""}); notifyConversationListListeners(); } + public void setDate(long threadId, long date) { + ContentValues contentValues = new ContentValues(1); + 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(); + } + 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; @@ -391,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; } @@ -412,9 +438,9 @@ public class ThreadDatabase extends Database { " ON " + TABLE_NAME + "." + ADDRESS + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ADDRESS + " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ADDRESS + " = " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + - " WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + MESSAGE_COUNT + " = " + UNREAD_COUNT + " AND " + - RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " + + " WHERE " + MESSAGE_COUNT + " != 0 AND " + ARCHIVED + " = 0 AND " + HAS_SENT + " = 0 AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.APPROVED + " = 0 AND " + + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCK + " = 0 AND " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " IS NULL"; cursor = db.rawQuery(query, null); @@ -433,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 + @@ -441,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()) @@ -455,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); } @@ -466,7 +492,7 @@ public class ThreadDatabase extends Database { } public Cursor getApprovedConversationList() { - String where = "((" + MESSAGE_COUNT + " != 0 AND (" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%')) OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + OPEN_GROUP_PREFIX + "%') " + + String where = "((" + HAS_SENT + " = 1 OR " + RecipientDatabase.APPROVED + " = 1 OR "+ GroupDatabase.TABLE_NAME +"."+GROUP_ID+" LIKE '"+CLOSED_GROUP_PREFIX+"%') OR " + GroupDatabase.TABLE_NAME + "." + GROUP_ID + " LIKE '" + COMMUNITY_PREFIX + "%') " + "AND " + ARCHIVED + " = 0 "; return getConversationList(where); } @@ -479,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; } @@ -502,13 +522,50 @@ public class ThreadDatabase extends Database { return db.rawQuery(query, null); } - public void setLastSeen(long threadId) { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - ContentValues contentValues = new ContentValues(1); - contentValues.put(LAST_SEEN, System.currentTimeMillis()); + /** + * @param threadId + * @param timestamp + * @return true if we have set the last seen for the thread, false if there were no messages in the thread + */ + public boolean setLastSeen(long threadId, long timestamp) { + // edge case where we set the last seen time for a conversation before it loads messages (joining community for example) + MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); + Recipient forThreadId = getRecipientForThreadId(threadId); + if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && forThreadId != null && forThreadId.isCommunityRecipient()) return false; + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + ContentValues contentValues = new ContentValues(1); + long lastSeenTime = timestamp == -1 ? SnodeAPI.getNowWithOffset() : timestamp; + contentValues.put(LAST_SEEN, lastSeenTime); + db.beginTransaction(); db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)}); + String smsCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.READ+" = 0"; + String smsMentionCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.READ+" = 0 AND s."+SmsDatabase.HAS_MENTION+" = 1"; + String smsReactionCountSubQuery = "SELECT COUNT(*) FROM "+SmsDatabase.TABLE_NAME+" AS s WHERE t."+ID+" = s."+SmsDatabase.THREAD_ID+" AND s."+SmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND s."+SmsDatabase.REACTIONS_UNREAD+" = 1"; + String mmsCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.READ+" = 0"; + String mmsMentionCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.READ+" = 0 AND m."+MmsDatabase.HAS_MENTION+" = 1"; + String mmsReactionCountSubQuery = "SELECT COUNT(*) FROM "+MmsDatabase.TABLE_NAME+" AS m WHERE t."+ID+" = m."+MmsDatabase.THREAD_ID+" AND m."+MmsDatabase.DATE_SENT+" > t."+LAST_SEEN+" AND m."+MmsDatabase.REACTIONS_UNREAD+" = 1"; + String allSmsUnread = "(("+smsCountSubQuery+") + ("+smsReactionCountSubQuery+"))"; + String allMmsUnread = "(("+mmsCountSubQuery+") + ("+mmsReactionCountSubQuery+"))"; + String allUnread = "(("+allSmsUnread+") + ("+allMmsUnread+"))"; + String allUnreadMention = "(("+smsMentionCountSubQuery+") + ("+mmsMentionCountSubQuery+"))"; + + String reflectUpdates = "UPDATE "+TABLE_NAME+" AS t SET "+UNREAD_COUNT+" = "+allUnread+", "+UNREAD_MENTION_COUNT+" = "+allUnreadMention+" WHERE "+ID+" = ?"; + db.execSQL(reflectUpdates, new Object[]{threadId}); + db.setTransactionSuccessful(); + db.endTransaction(); + notifyConversationListeners(threadId); notifyConversationListListeners(); + return true; + } + + /** + * @param threadId + * @return true if we have set the last seen for the thread, false if there were no messages in the thread + */ + public boolean setLastSeen(long threadId) { + return setLastSeen(threadId, -1); } public Pair<Long, Boolean> getLastSeenAndHasSent(long threadId) { @@ -528,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()) { @@ -611,13 +668,19 @@ public class ThreadDatabase extends Database { try { cursor = db.query(TABLE_NAME, new String[]{ID}, where, recipientsArg, null, null, null); - + long threadId; + boolean created = false; if (cursor != null && cursor.moveToFirst()) { - return cursor.getLong(cursor.getColumnIndexOrThrow(ID)); + threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); } else { DatabaseComponent.get(context).recipientDatabase().setProfileSharing(recipient, true); - return createThreadForRecipient(recipient.getAddress(), recipient.isGroupRecipient(), distributionType); + threadId = createThreadForRecipient(recipient.getAddress(), recipient.isGroupRecipient(), distributionType); + created = true; } + if (created && updateListener != null) { + updateListener.threadCreated(recipient.getAddress(), threadId); + } + return threadId; } finally { if (cursor != null) cursor.close(); @@ -656,13 +719,14 @@ public class ThreadDatabase extends Database { new String[] {String.valueOf(threadId)}); notifyConversationListeners(threadId); + notifyConversationListListeners(); } - public boolean update(long threadId, boolean unarchive) { + public boolean update(long threadId, boolean unarchive, boolean shouldDeleteOnEmpty) { MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); long count = mmsSmsDatabase.getConversationCount(threadId); - boolean shouldDeleteEmptyThread = deleteThreadOnEmpty(threadId); + boolean shouldDeleteEmptyThread = shouldDeleteOnEmpty && possibleToDeleteThreadOnEmpty(threadId); if (count == 0 && shouldDeleteEmptyThread) { deleteThread(threadId); @@ -670,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(); @@ -685,19 +746,18 @@ public class ThreadDatabase extends Database { updateThread(threadId, count, getFormattedBodyFor(record), getAttachmentUriFor(record), record.getTimestamp(), record.getDeliveryStatus(), record.getDeliveryReceiptCount(), record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount()); - notifyConversationListListeners(); return false; } else { if (shouldDeleteEmptyThread) { deleteThread(threadId); - notifyConversationListListeners(); return true; } + // todo: add empty snippet that clears existing data return false; } } finally { - if (reader != null) - reader.close(); + notifyConversationListListeners(); + notifyConversationListeners(threadId); } } @@ -709,23 +769,40 @@ public class ThreadDatabase extends Database { new String[] {String.valueOf(threadId)}); notifyConversationListeners(threadId); + notifyConversationListListeners(); } - public void markAllAsRead(long threadId, boolean isGroupRecipient) { - List<MarkedMessageInfo> messages = setRead(threadId, true); - if (isGroupRecipient) { - for (MarkedMessageInfo message: messages) { - MarkReadReceiver.scheduleDeletion(context, message.getExpirationInfo()); + public boolean isPinned(long threadId) { + SQLiteDatabase db = getReadableDatabase(); + Cursor cursor = db.query(TABLE_NAME, new String[]{IS_PINNED}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); + try { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0) == 1; } - } else { - MarkReadReceiver.process(context, messages); + return false; + } finally { + if (cursor != null) cursor.close(); } - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, false, 0); } - private boolean deleteThreadOnEmpty(long threadId) { + /** + * @param threadId + * @param isGroupRecipient + * @param lastSeenTime + * @return true if we have set the last seen for the thread, false if there were no messages in the thread + */ + public boolean markAllAsRead(long threadId, boolean isGroupRecipient, long lastSeenTime, boolean force) { + MmsSmsDatabase mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase(); + if (mmsSmsDatabase.getConversationCount(threadId) <= 0 && !force) return false; + List<MarkedMessageInfo> messages = setRead(threadId, lastSeenTime); + MarkReadReceiver.process(context, messages); + ApplicationContext.getInstance(context).messageNotifier.updateNotification(context, threadId); + return setLastSeen(threadId, lastSeenTime); + } + + 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) { @@ -768,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; @@ -777,77 +854,6 @@ public class ThreadDatabase extends Database { return query; } - @NotNull - public List<ThreadRecord> getHttpOxenOpenGroups() { - String where = TABLE_NAME+"."+ADDRESS+" LIKE ?"; - String selection = OpenGroupMigrator.HTTP_PREFIX+OpenGroupMigrator.OPEN_GET_SESSION_TRAILING_DOT_ENCODED +"%"; - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - String query = createQuery(where, 0); - Cursor cursor = db.rawQuery(query, new String[]{selection}); - - if (cursor == null) { - return Collections.emptyList(); - } - List<ThreadRecord> threads = new ArrayList<>(); - try { - Reader reader = readerFor(cursor); - ThreadRecord record; - while ((record = reader.getNext()) != null) { - threads.add(record); - } - } finally { - cursor.close(); - } - return threads; - } - - @NotNull - public List<ThreadRecord> getLegacyOxenOpenGroups() { - String where = TABLE_NAME+"."+ADDRESS+" LIKE ?"; - String selection = OpenGroupMigrator.LEGACY_GROUP_ENCODED_ID+"%"; - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - String query = createQuery(where, 0); - Cursor cursor = db.rawQuery(query, new String[]{selection}); - - if (cursor == null) { - return Collections.emptyList(); - } - List<ThreadRecord> threads = new ArrayList<>(); - try { - Reader reader = readerFor(cursor); - ThreadRecord record; - while ((record = reader.getNext()) != null) { - threads.add(record); - } - } finally { - cursor.close(); - } - return threads; - } - - @NotNull - public List<ThreadRecord> getHttpsOxenOpenGroups() { - String where = TABLE_NAME+"."+ADDRESS+" LIKE ?"; - String selection = OpenGroupMigrator.NEW_GROUP_ENCODED_ID+"%"; - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - String query = createQuery(where, 0); - Cursor cursor = db.rawQuery(query, new String[]{selection}); - if (cursor == null) { - return Collections.emptyList(); - } - List<ThreadRecord> threads = new ArrayList<>(); - try { - Reader reader = readerFor(cursor); - ThreadRecord record; - while ((record = reader.getNext()) != null) { - threads.add(record); - } - } finally { - cursor.close(); - } - return threads; - } - public void migrateEncodedGroup(long threadId, @NotNull String newEncodedGroupId) { ContentValues contentValues = new ContentValues(1); contentValues.put(ADDRESS, newEncodedGroupId); @@ -875,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; @@ -884,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; @@ -900,9 +910,10 @@ 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)); long type = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_TYPE)); boolean archived = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.ARCHIVED)) != 0; int status = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.STATUS)); @@ -917,8 +928,18 @@ public class ThreadDatabase extends Database { readReceiptCount = 0; } - return new ThreadRecord(body, snippetUri, recipient, date, count, - unreadCount, threadId, deliveryReceiptCount, status, type, + 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 d2266b3924..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 @@ -1,26 +1,30 @@ package org.thoughtcrime.securesms.database.helpers; - +import android.app.NotificationChannel; +import android.app.NotificationManager; import android.content.Context; import android.database.Cursor; import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; -import net.sqlcipher.database.SQLiteDatabase; -import net.sqlcipher.database.SQLiteDatabaseHook; -import net.sqlcipher.database.SQLiteOpenHelper; +import net.zetetic.database.sqlcipher.SQLiteConnection; +import net.zetetic.database.sqlcipher.SQLiteDatabase; +import net.zetetic.database.sqlcipher.SQLiteDatabaseHook; +import net.zetetic.database.sqlcipher.SQLiteOpenHelper; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.crypto.DatabaseSecret; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.BlindedIdMappingDatabase; +import org.thoughtcrime.securesms.database.ConfigDatabase; import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.EmojiSearchDatabase; +import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupMemberDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase; -import org.thoughtcrime.securesms.database.JobDatabase; import org.thoughtcrime.securesms.database.LokiAPIDatabase; import org.thoughtcrime.securesms.database.LokiBackupFilesDatabase; import org.thoughtcrime.securesms.database.LokiMessageDatabase; @@ -35,6 +39,12 @@ import org.thoughtcrime.securesms.database.SessionContactDatabase; import org.thoughtcrime.securesms.database.SessionJobDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities; + +import java.io.File; + +import network.loki.messenger.R; public class SQLCipherOpenHelper extends SQLiteOpenHelper { @@ -75,40 +85,198 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV36 = 57; private static final int lokiV37 = 58; private static final int lokiV38 = 59; + private static final int lokiV39 = 60; + private static final int lokiV40 = 61; + private static final int lokiV41 = 62; + private static final int lokiV42 = 63; + 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 = lokiV38; - private static final String DATABASE_NAME = "signal.db"; + 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"; private final Context context; private final DatabaseSecret databaseSecret; public SQLCipherOpenHelper(@NonNull Context context, @NonNull DatabaseSecret databaseSecret) { - super(context, DATABASE_NAME, null, DATABASE_VERSION, new SQLiteDatabaseHook() { - @Override - public void preKey(SQLiteDatabase db) { - db.rawExecSQL("PRAGMA cipher_default_kdf_iter = 1;"); - db.rawExecSQL("PRAGMA cipher_default_page_size = 4096;"); - } - - @Override - public void postKey(SQLiteDatabase db) { - db.rawExecSQL("PRAGMA kdf_iter = '1';"); - db.rawExecSQL("PRAGMA cipher_page_size = 4096;"); - // if not vacuumed in a while, perform that operation - long currentTime = System.currentTimeMillis(); - // 7 days - if (currentTime - TextSecurePreferences.getLastVacuumTime(context) > 604_800_000) { - db.rawExecSQL("VACUUM;"); - TextSecurePreferences.setLastVacuumNow(context); + super( + context, + DATABASE_NAME, + databaseSecret.asString(), + null, + DATABASE_VERSION, + MIN_DATABASE_VERSION, + null, + new SQLiteDatabaseHook() { + @Override + public void preKey(SQLiteConnection connection) { + SQLCipherOpenHelper.applySQLCipherPragmas(connection, true); } - } - }); + + @Override + public void postKey(SQLiteConnection connection) { + SQLCipherOpenHelper.applySQLCipherPragmas(connection, true); + + // if not vacuumed in a while, perform that operation + long currentTime = System.currentTimeMillis(); + // 7 days + if (currentTime - TextSecurePreferences.getLastVacuumTime(context) > 604_800_000) { + connection.execute("VACUUM;", null, null); + TextSecurePreferences.setLastVacuumNow(context); + } + } + }, + // Note: Now that we support concurrent database reads the migrations are actually non-blocking + // because of this we need to initially open the database with writeAheadLogging (WAL mode) disabled + // and enable it once the database officially opens it's connection (which will cause it to re-connect + // in WAL mode) - this is a little inefficient but will prevent SQL-related errors/crashes due to + // incomplete migrations + false + ); this.context = context.getApplicationContext(); this.databaseSecret = databaseSecret; } + private static void applySQLCipherPragmas(SQLiteConnection connection, boolean useSQLCipher4) { + if (useSQLCipher4) { + connection.execute("PRAGMA kdf_iter = '256000';", null, null); + } + else { + connection.execute("PRAGMA cipher_compatibility = 3;", null, null); + connection.execute("PRAGMA kdf_iter = '1';", null, null); + } + + connection.execute("PRAGMA cipher_page_size = 4096;", null, null); + } + + private static SQLiteDatabase open(String path, DatabaseSecret databaseSecret, boolean useSQLCipher4) { + return SQLiteDatabase.openDatabase(path, databaseSecret.asString(), null, SQLiteDatabase.OPEN_READWRITE, new SQLiteDatabaseHook() { + @Override + public void preKey(SQLiteConnection connection) { SQLCipherOpenHelper.applySQLCipherPragmas(connection, useSQLCipher4); } + + @Override + public void postKey(SQLiteConnection connection) { SQLCipherOpenHelper.applySQLCipherPragmas(connection, useSQLCipher4); } + }); + } + + public static void migrateSqlCipher3To4IfNeeded(@NonNull Context context, @NonNull DatabaseSecret databaseSecret) throws Exception { + String oldDbPath = context.getDatabasePath(CIPHER3_DATABASE_NAME).getPath(); + File oldDbFile = new File(oldDbPath); + + // If the old SQLCipher3 database file doesn't exist then no need to do anything + if (!oldDbFile.exists()) { return; } + + // Define the location for the new database + String newDbPath = context.getDatabasePath(DATABASE_NAME).getPath(); + File newDbFile = new File(newDbPath); + + try { + // If the new database file already exists then check if it's valid first, if it's in an + // invalid state we should delete it and try to migrate again + if (newDbFile.exists()) { + // If the old database hasn't been modified since the new database was created, then we can + // assume the user hasn't downgraded for some reason and made changes to the old database and + // can remove the old database file (it won't be used anymore) + if (oldDbFile.lastModified() <= newDbFile.lastModified()) { + try { + SQLiteDatabase newDb = SQLCipherOpenHelper.open(newDbPath, databaseSecret, true); + int version = newDb.getVersion(); + newDb.close(); + + // Make sure the new database has it's version set correctly (if not then the migration didn't + // fully succeed and the database will try to create all it's tables and immediately fail so + // we will need to remove and remigrate) + if (version > 0) { + // TODO: Delete 'CIPHER3_DATABASE_NAME' once enough time has past +// //noinspection ResultOfMethodCallIgnored +// oldDbFile.delete(); + return; + } + } + catch (Exception e) { + Log.i(TAG, "Failed to retrieve version from new database, assuming invalid and remigrating"); + } + } + + // If the old database does have newer changes then the new database could have stale/invalid + // data and we should re-migrate to avoid losing any data or issues + if (!newDbFile.delete()) { + throw new Exception("Failed to remove invalid new database"); + } + } + + if (!newDbFile.createNewFile()) { + throw new Exception("Failed to create new database"); + } + + // Open the old database and extract it's version + SQLiteDatabase oldDb = SQLCipherOpenHelper.open(oldDbPath, databaseSecret, false); + int oldDbVersion = oldDb.getVersion(); + + // Export the old database to the new one (will have the default 'kdf_iter' and 'page_size' settings) + oldDb.rawExecSQL( + String.format("ATTACH DATABASE '%s' AS sqlcipher4 KEY '%s'", newDbPath, databaseSecret.asString()) + ); + Cursor cursor = oldDb.rawQuery("SELECT sqlcipher_export('sqlcipher4')"); + cursor.moveToLast(); + cursor.close(); + oldDb.rawExecSQL("DETACH DATABASE sqlcipher4"); + oldDb.close(); + + // Open the newly migrated database (to ensure it works) and set it's version so we don't try + // to run any of our custom migrations + SQLiteDatabase newDb = SQLCipherOpenHelper.open(newDbPath, databaseSecret, true); + newDb.setVersion(oldDbVersion); + newDb.close(); + + // TODO: Delete 'CIPHER3_DATABASE_NAME' once enough time has past + // Remove the old database file since it will no longer be used +// //noinspection ResultOfMethodCallIgnored +// oldDbFile.delete(); + } + catch (Exception e) { + Log.e(TAG, "Migration from SQLCipher3 to SQLCipher4 failed", e); + + // If an exception was thrown then we should remove the new database file (it's probably invalid) + if (!newDbFile.delete()) { + Log.e(TAG, "Unable to delete invalid new database file"); + } + + // Notify the user of the issue so they know they can downgrade until the issue is fixed + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + String channelId = context.getString(R.string.NotificationChannel_failures); + + if (NotificationChannels.supported()) { + NotificationChannel channel = new NotificationChannel(channelId, channelId, NotificationManager.IMPORTANCE_HIGH); + channel.enableVibration(true); + notificationManager.createNotificationChannel(channel); + } + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_notification) + .setColor(context.getResources().getColor(R.color.textsecure_primary)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setContentTitle(context.getString(R.string.ErrorNotifier_migration)) + .setContentText(context.getString(R.string.ErrorNotifier_migration_downgrade)) + .setAutoCancel(true); + + if (!NotificationChannels.supported()) { + builder.setPriority(NotificationCompat.PRIORITY_HIGH); + } + + notificationManager.notify(5874, builder.build()); + + // Throw the error (app will crash but there is nothing else we can do unfortunately) + throw e; + } + } + @Override public void onCreate(SQLiteDatabase db) { db.execSQL(SmsDatabase.CREATE_TABLE); @@ -123,9 +291,6 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { for (String sql : SearchDatabase.CREATE_TABLE) { db.execSQL(sql); } - for (String sql : JobDatabase.CREATE_TABLE) { - db.execSQL(sql); - } db.execSQL(LokiAPIDatabase.getCreateSnodePoolTableCommand()); db.execSQL(LokiAPIDatabase.getCreateOnionRequestPathTableCommand()); db.execSQL(LokiAPIDatabase.getCreateSwarmTableCommand()); @@ -150,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()); @@ -163,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); @@ -180,24 +348,31 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(LokiAPIDatabase.RESET_SEQ_NO); // probably not needed but consistent with all migrations db.execSQL(EmojiSearchDatabase.CREATE_EMOJI_SEARCH_TABLE_COMMAND); db.execSQL(ReactionDatabase.CREATE_REACTION_TABLE_COMMAND); + db.execSQL(ThreadDatabase.getUnreadMentionCountCommand()); + db.execSQL(SmsDatabase.CREATE_HAS_MENTION_COMMAND); + db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND); + db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND); + 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); + executeStatements(db, ReactionDatabase.CREATE_INDEXS); executeStatements(db, ReactionDatabase.CREATE_REACTION_TRIGGERS); + db.execSQL(RecipientDatabase.getAddWrapperHash()); + db.execSQL(RecipientDatabase.getAddBlocksCommunityMessageRequests()); + db.execSQL(LokiAPIDatabase.CREATE_LAST_LEGACY_MESSAGE_TABLE); } @Override public void onConfigure(SQLiteDatabase db) { super.onConfigure(db); - // Loki - Enable write ahead logging mode and increase the cache size. - // This should be disabled if we ever run into serious race condition bugs. - db.enableWriteAheadLogging(); + db.execSQL("PRAGMA cache_size = 10000"); } @@ -414,18 +589,63 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(EmojiSearchDatabase.CREATE_EMOJI_SEARCH_TABLE_COMMAND); } + if (oldVersion < lokiV39) { + executeStatements(db, ReactionDatabase.CREATE_INDEXS); + } + + if (oldVersion < lokiV40) { + db.execSQL(ThreadDatabase.getUnreadMentionCountCommand()); + db.execSQL(SmsDatabase.CREATE_HAS_MENTION_COMMAND); + db.execSQL(MmsDatabase.CREATE_HAS_MENTION_COMMAND); + } + + if (oldVersion < lokiV41) { + db.execSQL(ConfigDatabase.CREATE_CONFIG_TABLE_COMMAND); + db.execSQL(ConfigurationMessageUtilities.DELETE_INACTIVE_GROUPS); + db.execSQL(ConfigurationMessageUtilities.DELETE_INACTIVE_ONE_TO_ONES); + } + + if (oldVersion < lokiV42) { + db.execSQL(RecipientDatabase.getAddWrapperHash()); + } + + 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(); } } - public SQLiteDatabase getReadableDatabase() { - return getReadableDatabase(databaseSecret.asString()); - } + @Override + public void onOpen(SQLiteDatabase db) { + super.onOpen(db); - public SQLiteDatabase getWritableDatabase() { - return getWritableDatabase(databaseSecret.asString()); + // Now that the database is officially open (ie. the migrations are completed) we want to enable + // write ahead logging (WAL mode) to officially support concurrent read connections + db.enableWriteAheadLogging(); } public void markCurrent(SQLiteDatabase db) { 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 ef0f4b54f3..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,12 +78,22 @@ 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); + } + + public boolean isResyncing() { + return MmsSmsColumns.Types.isResyncingType(type); + } + + public boolean isSyncFailed() { + return MmsSmsColumns.Types.isSyncFailedMessageType(type); } public boolean isFailed() { @@ -87,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; } @@ -97,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/MediaMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java index 570cb48bce..1b566169d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java @@ -57,12 +57,12 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { long expiresIn, long expireStarted, int readReceiptCount, @Nullable Quote quote, @NonNull List<Contact> contacts, @NonNull List<LinkPreview> linkPreviews, - @NonNull List<ReactionRecord> reactions, boolean unidentified) + @NonNull List<ReactionRecord> reactions, boolean unidentified, boolean hasMention) { super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures, expiresIn, expireStarted, slideDeck, readReceiptCount, quote, contacts, - linkPreviews, unidentified, reactions); + linkPreviews, unidentified, reactions, hasMention); this.partCount = partCount; } 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 da71103753..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,8 +31,10 @@ 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; /** * The base class for message record models that are displayed in @@ -50,7 +52,12 @@ public abstract class MessageRecord extends DisplayRecord { private final long expireStarted; private final boolean unidentified; public final long id; - private final List<ReactionRecord> reactions; + private final List<ReactionRecord> reactions; + private final boolean hasMention; + + public final boolean isNotDisappearAfterRead() { + return expireStarted == getTimestamp(); + } public abstract boolean isMms(); public abstract boolean isMmsNotification(); @@ -62,7 +69,7 @@ public abstract class MessageRecord extends DisplayRecord { List<IdentityKeyMismatch> mismatches, List<NetworkFailure> networkFailures, long expiresIn, long expireStarted, - int readReceiptCount, boolean unidentified, List<ReactionRecord> reactions) + int readReceiptCount, boolean unidentified, List<ReactionRecord> reactions, boolean hasMention) { super(body, conversationRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, readReceiptCount); @@ -74,6 +81,7 @@ public abstract class MessageRecord extends DisplayRecord { this.expireStarted = expireStarted; this.unidentified = unidentified; this.reactions = reactions; + this.hasMention = hasMention; } public long getId() { @@ -96,6 +104,8 @@ public abstract class MessageRecord extends DisplayRecord { } public long getExpireStarted() { return expireStarted; } + public boolean getHasMention() { return hasMention; } + public boolean isMediaPending() { return false; } @@ -111,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()))); @@ -140,14 +151,16 @@ public abstract class MessageRecord extends DisplayRecord { return spannable; } + @Override public boolean equals(Object other) { return other instanceof MessageRecord - && ((MessageRecord) other).getId() == getId() - && ((MessageRecord) other).isMms() == isMms(); + && ((MessageRecord) other).getId() == getId() + && ((MessageRecord) other).isMms() == isMms(); } + @Override public int hashCode() { - return (int)getId(); + return Objects.hash(id, isMms()); } public @NonNull List<ReactionRecord> getReactions() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java index 46e4199622..9f34f3fa0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -2,13 +2,15 @@ package org.thoughtcrime.securesms.database.model; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.session.libsession.utilities.Contact; + import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview; -import org.session.libsession.utilities.recipients.Recipient; +import org.session.libsession.utilities.Contact; import org.session.libsession.utilities.IdentityKeyMismatch; import org.session.libsession.utilities.NetworkFailure; +import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; + import java.util.LinkedList; import java.util.List; @@ -25,9 +27,9 @@ public abstract class MmsMessageRecord extends MessageRecord { List<NetworkFailure> networkFailures, long expiresIn, long expireStarted, @NonNull SlideDeck slideDeck, int readReceiptCount, @Nullable Quote quote, @NonNull List<Contact> contacts, - @NonNull List<LinkPreview> linkPreviews, boolean unidentified, List<ReactionRecord> reactions) + @NonNull List<LinkPreview> linkPreviews, boolean unidentified, List<ReactionRecord> reactions, boolean hasMention) { - super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, expiresIn, expireStarted, readReceiptCount, unidentified, reactions); + super(id, body, conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, expiresIn, expireStarted, readReceiptCount, unidentified, reactions, hasMention); this.slideDeck = slideDeck; this.quote = quote; this.contacts.addAll(contacts); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java index c1c87800d2..9fb4047879 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java @@ -50,12 +50,12 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord { long dateSent, long dateReceived, int deliveryReceiptCount, long threadId, byte[] contentLocation, long messageSize, long expiry, int status, byte[] transactionId, long mailbox, - SlideDeck slideDeck, int readReceiptCount) + SlideDeck slideDeck, int readReceiptCount, boolean hasMention) { super(id, "", conversationRecipient, individualRecipient, dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, emptyList(), emptyList(), - 0, 0, slideDeck, readReceiptCount, null, emptyList(), emptyList(), false, emptyList()); + 0, 0, slideDeck, readReceiptCount, null, emptyList(), emptyList(), false, emptyList(), hasMention); this.contentLocation = contentLocation; this.messageSize = messageSize; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/Quote.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/Quote.java index e79626c66b..4fd22ce8a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/Quote.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/Quote.java @@ -8,6 +8,8 @@ import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel; import org.session.libsession.utilities.Address; import org.thoughtcrime.securesms.mms.SlideDeck; +import java.util.Objects; + public class Quote { private final long id; @@ -47,4 +49,17 @@ public class Quote { public QuoteModel getQuoteModel() { return new QuoteModel(id, author, text, missing, attachment.asAttachments()); } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Quote quote = (Quote) o; + return id == quote.id && missing == quote.missing && Objects.equals(author, quote.author) && Objects.equals(text, quote.text) && Objects.equals(attachment, quote.attachment); + } + + @Override + public int hashCode() { + return Objects.hash(id, author, text, missing, attachment); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java index c1d50def2f..83ee921a2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java @@ -43,12 +43,12 @@ public class SmsMessageRecord extends MessageRecord { long type, long threadId, int status, List<IdentityKeyMismatch> mismatches, long expiresIn, long expireStarted, - int readReceiptCount, boolean unidentified, List<ReactionRecord> reactions) + int readReceiptCount, boolean unidentified, List<ReactionRecord> reactions, boolean hasMention) { super(id, body, recipient, individualRecipient, dateSent, dateReceived, threadId, status, deliveryReceiptCount, type, mismatches, new LinkedList<>(), - expiresIn, expireStarted, readReceiptCount, unidentified, reactions); + expiresIn, expireStarted, readReceiptCount, unidentified, reactions, hasMention); } public long getType() { 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 6ce69a591a..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,41 +43,41 @@ 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; private final int distributionType; private final boolean archived; private final long expiresIn; private final long lastSeen; private final boolean pinned; - private final int recipientHash; + private final int initialRecipientHash; public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri, - @NonNull Recipient recipient, long date, long count, int unreadCount, - long threadId, int deliveryReceiptCount, int status, long snippetType, - int distributionType, boolean archived, long expiresIn, long lastSeen, - int readReceiptCount, boolean pinned) + @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.count = count; - this.unreadCount = unreadCount; - this.distributionType = distributionType; - this.archived = archived; - this.expiresIn = expiresIn; - this.lastSeen = lastSeen; - this.pinned = pinned; - this.recipientHash = recipient.hashCode(); + this.snippetUri = snippetUri; + this.lastMessage = lastMessage; + this.count = count; + this.unreadCount = unreadCount; + this.unreadMentionCount = unreadMentionCount; + this.distributionType = distributionType; + this.archived = archived; + this.expiresIn = expiresIn; + this.lastSeen = lastSeen; + this.pinned = pinned; + this.initialRecipientHash = recipient.hashCode(); } public @Nullable Uri getSnippetUri() { return snippetUri; } - public int getRecipientHash() { - return recipientHash; - } - @Override public SpannableString getDisplayBody(@NonNull Context context) { if (isGroupUpdateMessage()) { @@ -153,6 +153,10 @@ public class ThreadRecord extends DisplayRecord { return unreadCount; } + public int getUnreadMentionCount() { + return unreadMentionCount; + } + public long getDate() { return getDateReceived(); } @@ -176,4 +180,8 @@ public class ThreadRecord extends DisplayRecord { public boolean isPinned() { return pinned; } + + public int getInitialRecipientHash() { + return initialRecipientHash; + } } 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 6f26c6ae3a..a9a72e7665 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.dependencies import dagger.Binds import dagger.Module +import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.session.libsession.utilities.AppTextSecurePreferences @@ -18,5 +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 new file mode 100644 index 0000000000..8379e1a23b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -0,0 +1,251 @@ +package org.thoughtcrime.securesms.dependencies + +import android.content.Context +import android.os.Trace +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.Contacts +import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.UserGroupsConfig +import network.loki.messenger.libsession_util.UserProfile +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.ConfigFactoryUpdateListener +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.ConfigDatabase +import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get +import org.thoughtcrime.securesms.groups.GroupManager +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities + +class ConfigFactory( + private val context: Context, + private val configDatabase: ConfigDatabase, + private val maybeGetUserInfo: () -> Pair<ByteArray, String>? +) : + ConfigFactoryProtocol { + companion object { + // This is a buffer period within which we will process messages which would result in a + // config change, any message which would normally result in a config change which was sent + // before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not actually have + // it's changes applied (control text will still be added though) + val configChangeBufferPeriod: Long = (2 * 60 * 1000) + } + + fun keyPairChanged() { // this should only happen restoring or clearing data + _userConfig?.free() + _contacts?.free() + _convoVolatileConfig?.free() + _userConfig = null + _contacts = null + _convoVolatileConfig = null + } + + private val userLock = Object() + private var _userConfig: UserProfile? = null + private val contactsLock = Object() + private var _contacts: Contacts? = null + private val convoVolatileLock = Object() + private var _convoVolatileConfig: ConversationVolatileConfig? = null + private val userGroupsLock = Object() + private var _userGroups: UserGroupsConfig? = null + + private val isConfigForcedOn by lazy { TextSecurePreferences.hasForcedNewConfig(context) } + + private val listeners: MutableList<ConfigFactoryUpdateListener> = mutableListOf() + fun registerListener(listener: ConfigFactoryUpdateListener) { + listeners += listener + } + + fun unregisterListener(listener: ConfigFactoryUpdateListener) { + listeners -= listener + } + + private inline fun <T> synchronizedWithLog(lock: Any, body: ()->T): T { + Trace.beginSection("synchronizedWithLog") + val result = synchronized(lock) { + body() + } + Trace.endSection() + return result + } + + override val user: UserProfile? + get() = synchronizedWithLog(userLock) { + if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null + if (_userConfig == null) { + val (secretKey, publicKey) = maybeGetUserInfo() ?: return null + val userDump = configDatabase.retrieveConfigAndHashes( + SharedConfigMessage.Kind.USER_PROFILE.name, + publicKey + ) + _userConfig = if (userDump != null) { + UserProfile.newInstance(secretKey, userDump) + } else { + ConfigurationMessageUtilities.generateUserProfileConfigDump()?.let { dump -> + UserProfile.newInstance(secretKey, dump) + } ?: UserProfile.newInstance(secretKey) + } + } + _userConfig + } + + override val contacts: Contacts? + get() = synchronizedWithLog(contactsLock) { + if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null + if (_contacts == null) { + val (secretKey, publicKey) = maybeGetUserInfo() ?: return null + val contactsDump = configDatabase.retrieveConfigAndHashes( + SharedConfigMessage.Kind.CONTACTS.name, + publicKey + ) + _contacts = if (contactsDump != null) { + Contacts.newInstance(secretKey, contactsDump) + } else { + ConfigurationMessageUtilities.generateContactConfigDump()?.let { dump -> + Contacts.newInstance(secretKey, dump) + } ?: Contacts.newInstance(secretKey) + } + } + _contacts + } + + override val convoVolatile: ConversationVolatileConfig? + get() = synchronizedWithLog(convoVolatileLock) { + if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null + if (_convoVolatileConfig == null) { + val (secretKey, publicKey) = maybeGetUserInfo() ?: return null + val convoDump = configDatabase.retrieveConfigAndHashes( + SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name, + publicKey + ) + _convoVolatileConfig = if (convoDump != null) { + ConversationVolatileConfig.newInstance(secretKey, convoDump) + } else { + ConfigurationMessageUtilities.generateConversationVolatileDump(context) + ?.let { dump -> + ConversationVolatileConfig.newInstance(secretKey, dump) + } ?: ConversationVolatileConfig.newInstance(secretKey) + } + } + _convoVolatileConfig + } + + override val userGroups: UserGroupsConfig? + get() = synchronizedWithLog(userGroupsLock) { + if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return null + if (_userGroups == null) { + val (secretKey, publicKey) = maybeGetUserInfo() ?: return null + val userGroupsDump = configDatabase.retrieveConfigAndHashes( + SharedConfigMessage.Kind.GROUPS.name, + publicKey + ) + _userGroups = if (userGroupsDump != null) { + UserGroupsConfig.Companion.newInstance(secretKey, userGroupsDump) + } else { + ConfigurationMessageUtilities.generateUserGroupDump(context)?.let { dump -> + UserGroupsConfig.Companion.newInstance(secretKey, dump) + } ?: UserGroupsConfig.newInstance(secretKey) + } + } + _userGroups + } + + override fun getUserConfigs(): List<ConfigBase> = + listOfNotNull(user, contacts, convoVolatile, userGroups) + + + private fun persistUserConfigDump(timestamp: Long) = synchronized(userLock) { + val dumped = user?.dump() ?: return + val (_, publicKey) = maybeGetUserInfo() ?: return + configDatabase.storeConfig(SharedConfigMessage.Kind.USER_PROFILE.name, publicKey, dumped, timestamp) + } + + private fun persistContactsConfigDump(timestamp: Long) = synchronized(contactsLock) { + val dumped = contacts?.dump() ?: return + val (_, publicKey) = maybeGetUserInfo() ?: return + configDatabase.storeConfig(SharedConfigMessage.Kind.CONTACTS.name, publicKey, dumped, timestamp) + } + + private fun persistConvoVolatileConfigDump(timestamp: Long) = synchronized(convoVolatileLock) { + val dumped = convoVolatile?.dump() ?: return + val (_, publicKey) = maybeGetUserInfo() ?: return + configDatabase.storeConfig( + SharedConfigMessage.Kind.CONVO_INFO_VOLATILE.name, + publicKey, + dumped, + timestamp + ) + } + + private fun persistUserGroupsConfigDump(timestamp: Long) = synchronized(userGroupsLock) { + val dumped = userGroups?.dump() ?: return + val (_, publicKey) = maybeGetUserInfo() ?: return + configDatabase.storeConfig(SharedConfigMessage.Kind.GROUPS.name, publicKey, dumped, timestamp) + } + + override fun persist(forConfigObject: ConfigBase, timestamp: Long) { + try { + listeners.forEach { listener -> + listener.notifyUpdates(forConfigObject, timestamp) + } + when (forConfigObject) { + is UserProfile -> persistUserConfigDump(timestamp) + is Contacts -> persistContactsConfigDump(timestamp) + is ConversationVolatileConfig -> persistConvoVolatileConfigDump(timestamp) + is UserGroupsConfig -> persistUserGroupsConfigDump(timestamp) + else -> throw UnsupportedOperationException("Can't support type of ${forConfigObject::class.simpleName} yet") + } + } catch (e: Exception) { + Log.e("Loki", "failed to persist ${forConfigObject.javaClass.simpleName}", e) + } + } + + override fun conversationInConfig( + publicKey: String?, + groupPublicKey: String?, + openGroupId: String?, + visibleOnly: Boolean + ): Boolean { + if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true + + val (_, userPublicKey) = maybeGetUserInfo() ?: return true + + if (openGroupId != null) { + val userGroups = userGroups ?: return false + val threadId = GroupManager.getOpenGroupThreadID(openGroupId, context) + val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return false + + // Not handling the `hidden` behaviour for communities so just indicate the existence + return (userGroups.getCommunityInfo(openGroup.server, openGroup.room) != null) + } + else if (groupPublicKey != null) { + val userGroups = userGroups ?: return false + + // Not handling the `hidden` behaviour for legacy groups so just indicate the existence + return (userGroups.getLegacyGroupInfo(groupPublicKey) != null) + } + else if (publicKey == userPublicKey) { + val user = user ?: return false + + return (!visibleOnly || user.getNtsPriority() != ConfigBase.PRIORITY_HIDDEN) + } + else if (publicKey != null) { + val contacts = contacts ?: return false + val targetContact = contacts.get(publicKey) ?: return false + + return (!visibleOnly || targetContact.priority != ConfigBase.PRIORITY_HIDDEN) + } + + return false + } + + override fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean { + if (!ConfigBase.isNewConfigEnabled(isConfigForcedOn, SnodeAPI.nowWithOffset)) return true + + val lastUpdateTimestampMs = configDatabase.retrieveConfigLastUpdateTimestamp(variant, publicKey) + + // Ensure the change occurred after the last config message was handled (minus the buffer period) + return (changeTimestampMs >= (lastUpdateTimestampMs - ConfigFactory.configChangeBufferPeriod)) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/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 648b9c43ec..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 @@ -32,7 +33,6 @@ interface DatabaseComponent { fun recipientDatabase(): RecipientDatabase fun groupReceiptDatabase(): GroupReceiptDatabase fun searchDatabase(): SearchDatabase - fun jobDatabase(): JobDatabase fun lokiAPIDatabase(): LokiAPIDatabase fun lokiMessageDatabase(): LokiMessageDatabase fun lokiThreadDatabase(): LokiThreadDatabase @@ -46,4 +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 029daefbf1..30fb40d89a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/DatabaseModule.kt @@ -6,14 +6,15 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import net.sqlcipher.database.SQLiteDatabase 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 @@ -22,9 +23,13 @@ object DatabaseModule { @JvmStatic fun init(context: Context) { - SQLiteDatabase.loadLibs(context) + 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 @@ -33,6 +38,7 @@ object DatabaseModule { @Singleton fun provideOpenHelper(@ApplicationContext context: Context): SQLCipherOpenHelper { val dbSecret = DatabaseSecretProvider(context).orCreateDatabaseSecret + SQLCipherOpenHelper.migrateSqlCipher3To4IfNeeded(context, dbSecret) return SQLCipherOpenHelper(context, dbSecret) } @@ -85,10 +91,6 @@ object DatabaseModule { @Singleton fun searchDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = SearchDatabase(context,openHelper) - @Provides - @Singleton - fun provideJobDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = JobDatabase(context, openHelper) - @Provides @Singleton fun provideLokiApiDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = LokiAPIDatabase(context,openHelper) @@ -135,10 +137,22 @@ object DatabaseModule { @Provides @Singleton - fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = Storage(context,openHelper) + fun provideExpirationConfigurationDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper) = ExpirationConfigurationDatabase(context, openHelper) + + @Provides + @Singleton + fun provideStorage(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper, configFactory: ConfigFactory, threadDatabase: ThreadDatabase): Storage { + val storage = Storage(context,openHelper, configFactory) + threadDatabase.setUpdateListener(storage) + return storage + } @Provides @Singleton fun provideAttachmentProvider(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): MessageDataProvider = DatabaseAttachmentProvider(context, openHelper) + @Provides + @Singleton + fun provideConfigDatabase(@ApplicationContext context: Context, openHelper: SQLCipherOpenHelper): ConfigDatabase = ConfigDatabase(context, openHelper) + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/InjectableType.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/InjectableType.java deleted file mode 100644 index 033b3ef45a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/InjectableType.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.thoughtcrime.securesms.dependencies; - -public interface InjectableType { -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt new file mode 100644 index 0000000000..cd4b071338 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.dependencies + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.session.libsession.utilities.ConfigFactoryUpdateListener +import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.database.ConfigDatabase +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object SessionUtilModule { + + private fun maybeUserEdSecretKey(context: Context): ByteArray? { + val edKey = KeyPairUtilities.getUserED25519KeyPair(context) ?: return null + return edKey.secretKey.asBytes + } + + @Provides + @Singleton + fun provideConfigFactory(@ApplicationContext context: Context, configDatabase: ConfigDatabase): ConfigFactory = + ConfigFactory(context, configDatabase) { + val localUserPublicKey = TextSecurePreferences.getLocalNumber(context) + val secretKey = maybeUserEdSecretKey(context) + if (localUserPublicKey == null || secretKey == null) null + else secretKey to localUserPublicKey + }.apply { + registerListener(context as ConfigFactoryUpdateListener) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt index 8b880d2189..74e2cac4c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt @@ -98,7 +98,7 @@ class NewMessageFragment : Fragment() { private fun hideLoader() { binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) binding.loader.visibility = View.GONE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/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 new file mode 100644 index 0000000000..adeeeb91fa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt @@ -0,0 +1,62 @@ +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.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.libsignal.crypto.ecc.DjbECPublicKey +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.dependencies.ConfigFactory + +object ClosedGroupManager { + + fun silentlyRemoveGroup(context: Context, threadId: Long, groupPublicKey: String, groupID: String, userPublicKey: String, delete: Boolean = true) { + val storage = MessagingModuleConfiguration.shared.storage + // Mark the group as inactive + storage.setActive(groupID, false) + storage.removeClosedGroupPublicKey(groupPublicKey) + // Remove the key pairs + storage.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey) + storage.removeMember(groupID, Address.fromSerialized(userPublicKey)) + // Notify the PN server + PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey) + // Stop polling + ClosedGroupPollerV2.shared.stopPolling(groupPublicKey) + storage.cancelPendingMessageSendJobs(threadId) + ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) + if (delete) { + storage.deleteConversation(threadId) + } + } + + fun ConfigFactory.removeLegacyGroup(group: GroupRecord): Boolean { + val groups = userGroups ?: return false + if (!group.isClosedGroup) return false + val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId()) + return groups.eraseLegacyGroup(groupPublicKey) + } + + fun ConfigFactory.updateLegacyGroup(group: GroupRecord) { + val groups = userGroups ?: return + if (!group.isClosedGroup) return + val storage = MessagingModuleConfiguration.shared.storage + val threadId = storage.getThreadId(group.encodedId) ?: return + val groupPublicKey = GroupUtil.doubleEncodeGroupID(group.getId()) + val latestKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return + val legacyInfo = groups.getOrConstructLegacyGroupInfo(groupPublicKey) + val latestMemberMap = GroupUtil.createConfigMemberMap(group.members.map(Address::serialize), group.admins.map(Address::serialize)) + val toSet = legacyInfo.copy( + members = latestMemberMap, + name = group.title, + priority = if (storage.isPinned(threadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, + encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte + encSecKey = latestKeyPair.privateKey.serialize() + ) + groups.set(toSet) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/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 62e762316b..da982589c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/EditClosedGroupActivity.kt @@ -16,6 +16,7 @@ import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import nl.komponents.kovenant.Promise import nl.komponents.kovenant.task @@ -28,16 +29,28 @@ import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.contacts.SelectContactsActivity +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.groups.ClosedGroupManager.updateLegacyGroup import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.util.fadeIn import org.thoughtcrime.securesms.util.fadeOut import java.io.IOException +import javax.inject.Inject +@AndroidEntryPoint class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { + + @Inject + lateinit var groupConfigFactory: ConfigFactory + @Inject + lateinit var storage: Storage + private val originalMembers = HashSet<String>() private val zombies = HashSet<String>() private val members = HashSet<String>() @@ -163,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) { @@ -289,7 +303,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { isLoading = true loaderContainer.fadeIn() val promise: Promise<Any, Exception> = if (!members.contains(Recipient.from(this, Address.fromSerialized(userPublicKey), false))) { - MessageSender.explicitLeave(groupPublicKey!!, true) + MessageSender.explicitLeave(groupPublicKey!!, false) } else { task { if (hasNameChanged) { @@ -306,6 +320,7 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { promise.successUi { loaderContainer.fadeOut() isLoading = false + updateGroupConfig() finish() }.failUi { exception -> val message = if (exception is MessageSender.Error) exception.description else "An error occurred" @@ -316,5 +331,13 @@ class EditClosedGroupActivity : PassphraseRequiredActionBarActivity() { } } - class GroupMembers(val members: List<String>, val zombieMembers: List<String>) { } + private fun updateGroupConfig() { + val latestRecipient = storage.getRecipientSettings(Address.fromSerialized(groupID)) + ?: return Log.w("Loki", "No recipient settings when trying to update group config") + val latestGroup = storage.getGroup(groupID) + ?: return Log.w("Loki", "No group record when trying to update group config") + groupConfigFactory.updateLegacyGroup(latestGroup) + } + + class GroupMembers(val members: List<String>, val zombieMembers: List<String>) } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index a3d0e6d252..d4c5acf4ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -6,6 +6,7 @@ import android.graphics.Bitmap; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.DistributionTypes; import org.session.libsession.utilities.GroupUtil; @@ -16,11 +17,14 @@ import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import org.thoughtcrime.securesms.util.BitmapUtil; +import java.io.IOException; import java.util.HashSet; import java.util.LinkedList; import java.util.Objects; import java.util.Set; +import network.loki.messenger.libsession_util.UserGroupsConfig; + public class GroupManager { public static long getOpenGroupThreadID(String id, @NonNull Context context) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt index d37b17ef9f..ae59c3833e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinCommunityFragment.kt @@ -55,7 +55,7 @@ class JoinCommunityFragment : Fragment() { fun hideLoader() { binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) binding.loader.visibility = View.GONE } @@ -79,7 +79,7 @@ class JoinCommunityFragment : Fragment() { val openGroupID = "$sanitizedServer.${openGroup.room}" OpenGroupManager.add(sanitizedServer, openGroup.room, openGroup.serverPublicKey, requireContext()) val storage = MessagingModuleConfiguration.shared.storage - storage.onOpenGroupAdded(sanitizedServer) + storage.onOpenGroupAdded(sanitizedServer, openGroup.room) val threadID = GroupManager.getOpenGroupThreadID(openGroupID, requireContext()) val groupID = GroupUtil.getEncodedOpenGroupID(openGroupID.toByteArray()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt index d39ba709df..2754c70f69 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupManager.kt @@ -9,13 +9,13 @@ import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPoller import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import java.util.concurrent.Executors object OpenGroupManager { private val executorService = Executors.newScheduledThreadPool(4) - private var pollers = mutableMapOf<String, OpenGroupPoller>() // One for each server + private val pollers = mutableMapOf<String, OpenGroupPoller>() // One for each server private var isPolling = false private val pollUpdaterLock = Any() @@ -40,12 +40,18 @@ object OpenGroupManager { if (isPolling) { return } isPolling = true val storage = MessagingModuleConfiguration.shared.storage - val servers = storage.getAllOpenGroups().values.map { it.server }.toSet() - servers.forEach { server -> - pollers[server]?.stop() // Shouldn't be necessary - val poller = OpenGroupPoller(server, executorService) - poller.startIfNeeded() - pollers[server] = poller + val (serverGroups, toDelete) = storage.getAllOpenGroups().values.partition { storage.getThreadId(it) != null } + toDelete.forEach { openGroup -> + Log.w("Loki", "Need to delete a group") + delete(openGroup.server, openGroup.room, MessagingModuleConfiguration.shared.context) + } + + val servers = serverGroups.map { it.server }.toSet() + synchronized(pollUpdaterLock) { + servers.forEach { server -> + pollers[server]?.stop() // Shouldn't be necessary + pollers[server] = OpenGroupPoller(server, executorService).apply { startIfNeeded() } + } } } @@ -58,14 +64,14 @@ object OpenGroupManager { } @WorkerThread - fun add(server: String, room: String, publicKey: String, context: Context) { + fun add(server: String, room: String, publicKey: String, context: Context): Pair<Long,OpenGroupApi.RoomInfo?> { val openGroupID = "$server.$room" - var threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) + val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) val storage = MessagingModuleConfiguration.shared.storage val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() // Check it it's added already val existingOpenGroup = threadDB.getOpenGroupChat(threadID) - if (existingOpenGroup != null) { return } + if (existingOpenGroup != null) { return threadID to null } // Clear any existing data if needed storage.removeLastDeletionServerID(room, server) storage.removeLastMessageServerID(room, server) @@ -73,18 +79,20 @@ object OpenGroupManager { storage.removeLastOutboxMessageId(server) // Store the public key storage.setOpenGroupPublicKey(server, publicKey) - // Get capabilities - val capabilities = OpenGroupApi.getCapabilities(server).get() + // Get capabilities & room info + val (capabilities, info) = OpenGroupApi.getCapabilitiesAndRoomInfo(room, server).get() storage.setServerCapabilities(server, capabilities.capabilities) - // Get room info - val info = OpenGroupApi.getRoomInfo(room, server).get() - storage.setUserCount(room, server, info.activeUsers) // Create the group locally if not available already if (threadID < 0) { - threadID = GroupManager.createOpenGroup(openGroupID, context, null, info.name).threadId + GroupManager.createOpenGroup(openGroupID, context, null, info.name) } - val openGroup = OpenGroup(server, room, info.name, info.infoUpdates, publicKey) - threadDB.setOpenGroupChat(openGroup, threadID) + OpenGroupPoller.handleRoomPollInfo( + server = server, + roomToken = room, + pollInfo = info.toPollInfo(), + createGroupIfMissingWithPublicKey = publicKey + ) + return threadID to info } fun restartPollerForServer(server: String) { @@ -100,23 +108,27 @@ object OpenGroupManager { } } + @WorkerThread fun delete(server: String, room: String, context: Context) { val storage = MessagingModuleConfiguration.shared.storage + val configFactory = MessagingModuleConfiguration.shared.configFactory val threadDB = DatabaseComponent.get(context).threadDatabase() - val openGroupID = "$server.$room" + val openGroupID = "${server.removeSuffix("/")}.$room" val threadID = GroupManager.getOpenGroupThreadID(openGroupID, context) val recipient = threadDB.getRecipientForThreadId(threadID) ?: return threadDB.setThreadArchived(threadID) val groupID = recipient.address.serialize() // Stop the poller if needed val openGroups = storage.getAllOpenGroups().filter { it.value.server == server } - if (openGroups.count() == 1) { + if (openGroups.isNotEmpty()) { synchronized(pollUpdaterLock) { val poller = pollers[server] poller?.stop() pollers.remove(server) } } + configFactory.userGroups?.eraseCommunity(server, room) + configFactory.convoVolatile?.eraseCommunity(server, room) // Delete storage.removeLastDeletionServerID(room, server) storage.removeLastMessageServerID(room, server) @@ -124,18 +136,19 @@ object OpenGroupManager { storage.removeLastOutboxMessageId(server) val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase() lokiThreadDB.removeOpenGroupChat(threadID) - ThreadUtils.queue { - threadDB.deleteConversation(threadID) // Must be invoked on a background thread - GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread - } + storage.deleteConversation(threadID) // Must be invoked on a background thread + GroupManager.deleteGroup(groupID, context) // Must be invoked on a background thread + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) } - fun addOpenGroup(urlAsString: String, context: Context) { - val url = HttpUrl.parse(urlAsString) ?: return + @WorkerThread + fun addOpenGroup(urlAsString: String, context: Context): OpenGroupApi.RoomInfo? { + val url = HttpUrl.parse(urlAsString) ?: return null val server = OpenGroup.getServer(urlAsString) - val room = url.pathSegments().firstOrNull() ?: return - val publicKey = url.queryParameter("public_key") ?: return - add(server.toString().removeSuffix("/"), room, publicKey, context) // assume migrated from calling function + val room = url.pathSegments().firstOrNull() ?: return null + val publicKey = url.queryParameter("public_key") ?: return null + + return add(server.toString().removeSuffix("/"), room, publicKey, context).second // assume migrated from calling function } fun updateOpenGroup(openGroup: OpenGroup, context: Context) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupMigrator.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupMigrator.kt deleted file mode 100644 index 642d191614..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/OpenGroupMigrator.kt +++ /dev/null @@ -1,139 +0,0 @@ -package org.thoughtcrime.securesms.groups - -import androidx.annotation.VisibleForTesting -import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.Hex -import org.thoughtcrime.securesms.database.model.ThreadRecord -import org.thoughtcrime.securesms.dependencies.DatabaseComponent - -object OpenGroupMigrator { - const val HTTP_PREFIX = "__loki_public_chat_group__!687474703a2f2f" - private const val HTTPS_PREFIX = "__loki_public_chat_group__!68747470733a2f2f" - const val OPEN_GET_SESSION_TRAILING_DOT_ENCODED = "6f70656e2e67657473657373696f6e2e6f72672e" - const val LEGACY_GROUP_ENCODED_ID = "__loki_public_chat_group__!687474703a2f2f3131362e3230332e37302e33332e" // old IP based toByteArray() - const val NEW_GROUP_ENCODED_ID = "__loki_public_chat_group__!68747470733a2f2f6f70656e2e67657473657373696f6e2e6f72672e" // new URL based toByteArray() - - data class OpenGroupMapping(val stub: String, val legacyThreadId: Long, val newThreadId: Long?) - - @VisibleForTesting - fun Recipient.roomStub(): String? { - if (!isOpenGroupRecipient) return null - val serialized = address.serialize() - if (serialized.startsWith(LEGACY_GROUP_ENCODED_ID)) { - return serialized.replace(LEGACY_GROUP_ENCODED_ID,"") - } else if (serialized.startsWith(NEW_GROUP_ENCODED_ID)) { - return serialized.replace(NEW_GROUP_ENCODED_ID,"") - } else if (serialized.startsWith(HTTP_PREFIX + OPEN_GET_SESSION_TRAILING_DOT_ENCODED)) { - return serialized.replace(HTTP_PREFIX + OPEN_GET_SESSION_TRAILING_DOT_ENCODED, "") - } - return null - } - - @VisibleForTesting - fun getExistingMappings(legacy: List<ThreadRecord>, new: List<ThreadRecord>): List<OpenGroupMapping> { - val legacyStubsMapping = legacy.mapNotNull { thread -> - val stub = thread.recipient.roomStub() - stub?.let { it to thread.threadId } - } - val newStubsMapping = new.mapNotNull { thread -> - val stub = thread.recipient.roomStub() - stub?.let { it to thread.threadId } - } - return legacyStubsMapping.map { (legacyEncodedStub, legacyId) -> - // get 'new' open group thread ID if stubs match - OpenGroupMapping( - legacyEncodedStub, - legacyId, - newStubsMapping.firstOrNull { (newEncodedStub, _) -> newEncodedStub == legacyEncodedStub }?.second - ) - } - } - - @JvmStatic - fun migrate(databaseComponent: DatabaseComponent) { - // migrate thread db - val threadDb = databaseComponent.threadDatabase() - - val legacyOpenGroups = threadDb.legacyOxenOpenGroups - val httpBasedNewGroups = threadDb.httpOxenOpenGroups - if (legacyOpenGroups.isEmpty() && httpBasedNewGroups.isEmpty()) return // no need to migrate - - val newOpenGroups = threadDb.httpsOxenOpenGroups - val firstStepMigration = getExistingMappings(legacyOpenGroups, newOpenGroups) - - val secondStepMigration = getExistingMappings(httpBasedNewGroups, newOpenGroups) - - val groupDb = databaseComponent.groupDatabase() - val lokiApiDb = databaseComponent.lokiAPIDatabase() - val smsDb = databaseComponent.smsDatabase() - val mmsDb = databaseComponent.mmsDatabase() - val lokiMessageDatabase = databaseComponent.lokiMessageDatabase() - val lokiThreadDatabase = databaseComponent.lokiThreadDatabase() - - firstStepMigration.forEach { (stub, old, new) -> - val legacyEncodedGroupId = LEGACY_GROUP_ENCODED_ID+stub - if (new == null) { - val newEncodedGroupId = NEW_GROUP_ENCODED_ID+stub - // migrate thread and group encoded values - threadDb.migrateEncodedGroup(old, newEncodedGroupId) - groupDb.migrateEncodedGroup(legacyEncodedGroupId, newEncodedGroupId) - // migrate Loki API DB values - // decode the hex to bytes, decode byte array to string i.e. "oxen" or "session" - val decodedStub = Hex.fromStringCondensed(stub).decodeToString() - val legacyLokiServerId = "${OpenGroupApi.legacyDefaultServer}.$decodedStub" - val newLokiServerId = "${OpenGroupApi.defaultServer}.$decodedStub" - lokiApiDb.migrateLegacyOpenGroup(legacyLokiServerId, newLokiServerId) - // migrate loki thread db server info - val oldServerInfo = lokiThreadDatabase.getOpenGroupChat(old) - val newServerInfo = oldServerInfo!!.copy(server = OpenGroupApi.defaultServer, id = newLokiServerId) - lokiThreadDatabase.setOpenGroupChat(newServerInfo, old) - } else { - // has a legacy and a new one - // migrate SMS and MMS tables - smsDb.migrateThreadId(old, new) - mmsDb.migrateThreadId(old, new) - lokiMessageDatabase.migrateThreadId(old, new) - // delete group for legacy ID - groupDb.delete(legacyEncodedGroupId) - // delete thread for legacy ID - threadDb.deleteConversation(old) - lokiThreadDatabase.removeOpenGroupChat(old) - } - // maybe migrate jobs here - } - - secondStepMigration.forEach { (stub, old, new) -> - val legacyEncodedGroupId = HTTP_PREFIX + OPEN_GET_SESSION_TRAILING_DOT_ENCODED + stub - if (new == null) { - val newEncodedGroupId = NEW_GROUP_ENCODED_ID+stub - // migrate thread and group encoded values - threadDb.migrateEncodedGroup(old, newEncodedGroupId) - groupDb.migrateEncodedGroup(legacyEncodedGroupId, newEncodedGroupId) - // migrate Loki API DB values - // decode the hex to bytes, decode byte array to string i.e. "oxen" or "session" - val decodedStub = Hex.fromStringCondensed(stub).decodeToString() - val legacyLokiServerId = "${OpenGroupApi.httpDefaultServer}.$decodedStub" - val newLokiServerId = "${OpenGroupApi.defaultServer}.$decodedStub" - lokiApiDb.migrateLegacyOpenGroup(legacyLokiServerId, newLokiServerId) - // migrate loki thread db server info - val oldServerInfo = lokiThreadDatabase.getOpenGroupChat(old) - val newServerInfo = oldServerInfo!!.copy(server = OpenGroupApi.defaultServer, id = newLokiServerId) - lokiThreadDatabase.setOpenGroupChat(newServerInfo, old) - } else { - // has a legacy and a new one - // migrate SMS and MMS tables - smsDb.migrateThreadId(old, new) - mmsDb.migrateThreadId(old, new) - lokiMessageDatabase.migrateThreadId(old, new) - // delete group for legacy ID - groupDb.delete(legacyEncodedGroupId) - // delete thread for legacy ID - threadDb.deleteConversation(old) - lokiThreadDatabase.removeOpenGroupChat(old) - } - // maybe migrate jobs here - } - - } -} \ No newline at end of file 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 0b3c44a548..82b9f16dcd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -7,10 +7,14 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding import org.thoughtcrime.securesms.database.model.ThreadRecord -import org.thoughtcrime.securesms.util.UiModeUtilities +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.util.getConversationUnread +import javax.inject.Inject +@AndroidEntryPoint class ConversationOptionsBottomSheet(private val parentContext: Context) : BottomSheetDialogFragment(), View.OnClickListener { private lateinit var binding: FragmentConversationBottomSheetBinding //FIXME AC: Supplying a threadRecord directly into the field from an activity @@ -19,7 +23,10 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto // if we want to use dialog fragments properly. lateinit var thread: ThreadRecord + @Inject lateinit var configFactory: ConfigFactory + var onViewDetailsTapped: (() -> Unit?)? = null + var onCopyConversationId: (() -> Unit?)? = null var onPinTapped: (() -> Unit)? = null var onUnpinTapped: (() -> Unit)? = null var onBlockTapped: (() -> Unit)? = null @@ -37,6 +44,8 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto override fun onClick(v: View?) { when (v) { binding.detailsTextView -> onViewDetailsTapped?.invoke() + binding.copyConversationId -> onCopyConversationId?.invoke() + binding.copyCommunityUrl -> onCopyConversationId?.invoke() binding.pinTextView -> onPinTapped?.invoke() binding.unpinTextView -> onUnpinTapped?.invoke() binding.blockTextView -> onBlockTapped?.invoke() @@ -63,6 +72,10 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto } else { binding.detailsTextView.visibility = View.GONE } + binding.copyConversationId.visibility = if (!recipient.isGroupRecipient && !recipient.isLocalNumber) View.VISIBLE else View.GONE + binding.copyConversationId.setOnClickListener(this) + 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 binding.unMuteNotificationsTextView.setOnClickListener(this) @@ -70,7 +83,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto binding.notificationsTextView.isVisible = recipient.isGroupRecipient && !recipient.isMuted binding.notificationsTextView.setOnClickListener(this) binding.deleteTextView.setOnClickListener(this) - binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 + binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || configFactory.convoVolatile?.getConversationUnread(thread) == true binding.markAllAsReadTextView.setOnClickListener(this) binding.pinTextView.isVisible = !thread.isPinned binding.unpinTextView.isVisible = thread.isPinned @@ -81,7 +94,6 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto override fun onStart() { super.onStart() val window = dialog?.window ?: return - val isLightMode = UiModeUtilities.isDayUiMode(requireContext()) - window.setDimAmount(if (isLightMode) 0.1f else 0.75f) + window.setDimAmount(0.6f) } } \ No newline at end of file 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 bfa9b14489..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,14 +4,16 @@ 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.LayoutInflater import android.view.View import android.widget.LinearLayout import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewConversationBinding import org.session.libsession.utilities.recipients.Recipient @@ -19,23 +21,30 @@ import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.hig import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.getAccentColor +import org.thoughtcrime.securesms.util.getConversationUnread import java.util.Locale +import javax.inject.Inject +@AndroidEntryPoint class ConversationView : LinearLayout { - private lateinit var binding: ViewConversationBinding + + @Inject lateinit var configFactory: ConfigFactory + + private val binding: ViewConversationBinding by lazy { ViewConversationBinding.bind(this) } private val screenWidth = Resources.getSystem().displayMetrics.widthPixels var thread: ThreadRecord? = null // 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() } + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - private fun initialize() { - binding = ViewConversationBinding.inflate(LayoutInflater.from(context), this, true) + override fun onFinishInflate() { + super.onFinishInflate() layoutParams = RecyclerView.LayoutParams(screenWidth, RecyclerView.LayoutParams.WRAP_CONTENT) } // endregion @@ -53,12 +62,11 @@ class ConversationView : LinearLayout { } else { binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) } - background = if (thread.unreadCount > 0) { + binding.root.background = if (thread.unreadCount > 0) { ContextCompat.getDrawable(context, R.drawable.conversation_unread_background) } else { ContextCompat.getDrawable(context, R.drawable.conversation_view_background) } - binding.profilePictureView.root.glide = glide val unreadCount = thread.unreadCount if (thread.recipient.isBlocked) { binding.accentView.setBackgroundResource(R.color.destructive) @@ -71,7 +79,7 @@ class ConversationView : LinearLayout { // This would also not trigger the disappearing message timer which may or may not be desirable binding.accentView.visibility = if (unreadCount > 0 && !thread.isRead) View.VISIBLE else View.INVISIBLE } - val formattedUnreadCount = if (thread.isRead) { + val formattedUnreadCount = if (unreadCount == 0) { null } else { if (unreadCount < 10000) unreadCount.toString() else "9999+" @@ -79,12 +87,14 @@ class ConversationView : LinearLayout { binding.unreadCountTextView.text = formattedUnreadCount val textSize = if (unreadCount < 1000) 12.0f else 10.0f binding.unreadCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize) - binding.unreadCountIndicator.background.setTint(context.getAccentColor()) binding.unreadCountIndicator.isVisible = (unreadCount != 0 && !thread.isRead) - val senderDisplayName = getUserDisplayName(thread.recipient) + || (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 = 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) { @@ -93,17 +103,15 @@ 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) { - binding.typingIndicatorView.startAnimation() + binding.typingIndicatorView.root.startAnimation() } else { - binding.typingIndicatorView.stopAnimation() + binding.typingIndicatorView.root.stopAnimation() } - binding.typingIndicatorView.visibility = if (isTyping) View.VISIBLE else View.GONE + binding.typingIndicatorView.root.visibility = if (isTyping) View.VISIBLE else View.GONE binding.statusIndicatorImageView.visibility = View.VISIBLE when { !thread.isOutgoing -> binding.statusIndicatorImageView.visibility = View.GONE @@ -116,19 +124,28 @@ class ConversationView : LinearLayout { thread.isRead -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_filled_circle_check) else -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_check) } - binding.profilePictureView.root.update(thread.recipient) + binding.profilePictureView.update(thread.recipient) } fun recycle() { - binding.profilePictureView.root.recycle() + binding.profilePictureView.recycle() } - private fun getUserDisplayName(recipient: Recipient): String? { - return if (recipient.isLocalNumber) { - context.getString(R.string.note_to_self) - } else { - recipient.name // Internally uses the Contact API - } + 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 b21eb6ff13..c063f30538 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -1,35 +1,40 @@ package org.thoughtcrime.securesms.home -import android.content.BroadcastReceiver -import android.content.Context +import android.Manifest +import android.app.NotificationManager +import android.content.ClipData +import android.content.ClipboardManager import android.content.Intent -import android.content.IntentFilter +import android.os.Build import android.os.Bundle import android.text.SpannableString import android.widget.Toast import androidx.activity.viewModels -import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.lifecycle.repeatOnLifecycle 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 import org.greenrobot.eventbus.ThreadMode +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.ProfilePictureModifiedEvent @@ -39,7 +44,6 @@ import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.MuteDialog import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.conversation.start.NewConversationFragment import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 @@ -48,8 +52,10 @@ import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter @@ -58,18 +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.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 @@ -78,15 +85,22 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), SeedReminderViewDelegate, GlobalSearchInputLayout.GlobalSearchInputLayoutListener { + companion object { + const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING" + } + + private lateinit var binding: ActivityHomeBinding private lateinit var glide: GlideRequests - private var broadcastReceiver: BroadcastReceiver? = null @Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @Inject lateinit var recipientDatabase: RecipientDatabase + @Inject lateinit var storage: Storage @Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var textSecurePreferences: TextSecurePreferences + @Inject lateinit var configFactory: ConfigFactory + @Inject lateinit var pushRegistry: PushRegistry private val globalSearchViewModel by viewModels<GlobalSearchViewModel>() private val homeViewModel by viewModels<HomeViewModel>() @@ -95,14 +109,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), get() = textSecurePreferences.getLocalNumber()!! private val homeAdapter: HomeAdapter by lazy { - HomeAdapter(context = this, listener = this) + HomeAdapter(context = this, configFactory = configFactory, listener = this, ::showMessageRequests, ::hideMessageRequests) } private val globalSearchAdapter = GlobalSearchAdapter { model -> when (model) { is GlobalSearchAdapter.Model.Message -> { val threadId = model.messageResult.threadId - val timestamp = model.messageResult.receivedTimestampMs + val timestamp = model.messageResult.sentTimestampMs val author = model.messageResult.messageRecipient.address val intent = Intent(this, ConversationActivityV2::class.java) @@ -149,24 +163,24 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), // Set up Glide glide = GlideApp.with(this) // Set up toolbar buttons - binding.profileButton.root.glide = glide - binding.profileButton.root.setOnClickListener { openSettings() } + binding.profileButton.setOnClickListener { openSettings() } binding.searchViewContainer.setOnClickListener { binding.globalSearchInputLayout.requestFocus() } binding.sessionToolbar.disableClipping() // Set up seed reminder view - val hasViewedSeed = textSecurePreferences.getHasViewedSeed() - if (!hasViewedSeed) { - binding.seedReminderView.isVisible = true - binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated - binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1) - binding.seedReminderView.setProgress(80, false) - binding.seedReminderView.delegate = this@HomeActivity - } else { - binding.seedReminderView.isVisible = false + lifecycleScope.launchWhenStarted { + val hasViewedSeed = textSecurePreferences.getHasViewedSeed() + if (!hasViewedSeed) { + binding.seedReminderView.isVisible = true + binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated + binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1) + binding.seedReminderView.setProgress(80, false) + binding.seedReminderView.delegate = this@HomeActivity + } else { + binding.seedReminderView.isVisible = false + } } - setupMessageRequestsBanner() // Set up recycler view binding.globalSearchInputLayout.listener = this homeAdapter.setHasStableIds(true) @@ -174,21 +188,47 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), binding.recyclerView.adapter = homeAdapter binding.globalSearchRecycler.adapter = globalSearchAdapter + binding.configOutdatedView.setOnClickListener { + textSecurePreferences.setHasLegacyConfig(false) + updateLegacyConfigView() + } + // Set up empty state view binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() } IP2Country.configureIfNeeded(this@HomeActivity) - 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() + + // subscribe to outdated config updates, this should be removed after long enough time for device migration + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + TextSecurePreferences.events.filter { it == TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG }.collect { + updateLegacyConfigView() + } + } + } + + // 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() + } } } - this.broadcastReceiver = broadcastReceiver - LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged")) lifecycleScope.launchWhenStarted { launch(Dispatchers.IO) { @@ -196,13 +236,12 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), (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() } - // Set up typing observer + withContext(Dispatchers.Main) { updateProfileButton() TextSecurePreferences.events.filter { it == TextSecurePreferences.PROFILE_NAME_PREF }.collect { @@ -210,6 +249,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } } + // monitor the global search VM query launch { binding.globalSearchInputLayout.query @@ -256,12 +296,25 @@ 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)) { + 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) + } + } + } } override fun onInputFocusChanged(hasFocus: Boolean) { @@ -282,32 +335,9 @@ 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() } override fun onResume() { @@ -315,58 +345,35 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(true) if (textSecurePreferences.getLocalNumber() == null) { return; } // This can be the case after a secondary device is auto-cleared IdentityKeyUtil.checkUpdate(this) - binding.profileButton.root.recycle() // clear cached image before update tje profilePictureView - binding.profileButton.root.update() + binding.profileButton.recycle() // clear cached image before update tje profilePictureView + binding.profileButton.update() if (textSecurePreferences.getHasViewedSeed()) { binding.seedReminderView.isVisible = false } + + updateLegacyConfigView() + + // TODO: remove this after enough updates that we can rely on ConfigBase.isNewConfigEnabled to always return true + // This will only run if we aren't using new configs, as they are schedule to sync when there are changes applied if (textSecurePreferences.getConfigurationMessageSynced()) { lifecycleScope.launch(Dispatchers.IO) { ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity) } } - - // 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() - } - } - private fun updateEmptyState() { val threadCount = (binding.recyclerView.adapter)!!.itemCount binding.emptyStateContainer.isVisible = threadCount == 0 && binding.recyclerView.isVisible @@ -377,19 +384,20 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), if (event.recipient.isLocalNumber) { updateProfileButton() } else { - homeViewModel.tryUpdateChannel() + homeViewModel.tryReload() } } private fun updateProfileButton() { - binding.profileButton.root.publicKey = publicKey - binding.profileButton.root.displayName = textSecurePreferences.getProfileName() - binding.profileButton.root.recycle() - binding.profileButton.root.update() + binding.profileButton.publicKey = publicKey + binding.profileButton.displayName = textSecurePreferences.getProfileName() + binding.profileButton.recycle() + binding.profileButton.update() } // endregion // region Interaction + @Deprecated("Deprecated in Java") override fun onBackPressed() { if (binding.globalSearchRecycler.isVisible) { binding.globalSearchInputLayout.clearSearch(true) @@ -422,6 +430,24 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), userDetailsBottomSheet.arguments = bundle userDetailsBottomSheet.show(supportFragmentManager, userDetailsBottomSheet.tag) } + bottomSheet.onCopyConversationId = onCopyConversationId@{ + bottomSheet.dismiss() + if (!thread.recipient.isGroupRecipient && !thread.recipient.isLocalNumber) { + val clip = ClipData.newPlainText("Session ID", thread.recipient.address.toString()) + val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager + manager.setPrimaryClip(clip) + Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + } + 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 + + val clip = ClipData.newPlainText("Community URL", openGroup.joinURL) + val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager + manager.setPrimaryClip(clip) + Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() + } + } bottomSheet.onBlockTapped = { bottomSheet.dismiss() if (!thread.recipient.isBlocked) { @@ -464,35 +490,37 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } private fun blockConversation(thread: ThreadRecord) { - AlertDialog.Builder(this) - .setTitle(R.string.RecipientPreferenceActivity_block_this_contact_question) - .setMessage(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.RecipientPreferenceActivity_block) { dialog, _ -> - lifecycleScope.launch(Dispatchers.IO) { - recipientDatabase.setBlocked(thread.recipient, true) - withContext(Dispatchers.Main) { - binding.recyclerView.adapter!!.notifyDataSetChanged() - dialog.dismiss() - } + showSessionDialog { + title(R.string.RecipientPreferenceActivity_block_this_contact_question) + text(R.string.RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact) + button(R.string.RecipientPreferenceActivity_block) { + lifecycleScope.launch(Dispatchers.IO) { + storage.setBlocked(listOf(thread.recipient), true) + + withContext(Dispatchers.Main) { + binding.recyclerView.adapter!!.notifyDataSetChanged() } - }.show() + } + } + cancelButton() + } } private fun unblockConversation(thread: ThreadRecord) { - AlertDialog.Builder(this) - .setTitle(R.string.RecipientPreferenceActivity_unblock_this_contact_question) - .setMessage(R.string.RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.RecipientPreferenceActivity_unblock) { dialog, _ -> - lifecycleScope.launch(Dispatchers.IO) { - recipientDatabase.setBlocked(thread.recipient, false) - withContext(Dispatchers.Main) { - binding.recyclerView.adapter!!.notifyDataSetChanged() - dialog.dismiss() - } + showSessionDialog { + title(R.string.RecipientPreferenceActivity_unblock_this_contact_question) + text(R.string.RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact) + button(R.string.RecipientPreferenceActivity_unblock) { + lifecycleScope.launch(Dispatchers.IO) { + storage.setBlocked(listOf(thread.recipient), false) + + withContext(Dispatchers.Main) { + binding.recyclerView.adapter!!.notifyDataSetChanged() } - }.show() + } + } + cancelButton() + } } private fun setConversationMuted(thread: ThreadRecord, isMuted: Boolean) { @@ -504,7 +532,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } } else { - MuteDialog.show(this) { until: Long -> + showMuteDialog(this) { until -> lifecycleScope.launch(Dispatchers.IO) { recipientDatabase.setMuted(thread.recipient, until) withContext(Dispatchers.Main) { @@ -526,14 +554,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private fun setConversationPinned(threadId: Long, pinned: Boolean) { lifecycleScope.launch(Dispatchers.IO) { - threadDb.setPinned(threadId, pinned) - homeViewModel.tryUpdateChannel() + storage.setPinned(threadId, pinned) + homeViewModel.tryReload() } } private fun markAllAsRead(thread: ThreadRecord) { ThreadUtils.queue { - threadDb.markAllAsRead(thread.threadId, thread.recipient.isOpenGroupRecipient) + MessagingModuleConfiguration.shared.storage.markConversationAsRead(thread.threadId, SnodeAPI.nowWithOffset) } } @@ -550,48 +578,41 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } else { resources.getString(R.string.activity_home_delete_conversation_dialog_message) } - val dialog = AlertDialog.Builder(this) - dialog.setMessage(message) - dialog.setPositiveButton(R.string.yes) { _, _ -> - lifecycleScope.launch(Dispatchers.Main) { - val context = this@HomeActivity as Context - // Cancel any outstanding jobs - DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID) - // Send a leave group message if this is an active closed group - if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) { - var isClosedGroup: Boolean - var groupPublicKey: String? - try { - groupPublicKey = GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString() - isClosedGroup = DatabaseComponent.get(context).lokiAPIDatabase().isClosedGroup(groupPublicKey) - } catch (e: IOException) { - groupPublicKey = null - isClosedGroup = false + + showSessionDialog { + text(message) + button(R.string.yes) { + lifecycleScope.launch(Dispatchers.Main) { + val context = this@HomeActivity + // Cancel any outstanding jobs + DatabaseComponent.get(context).sessionJobDatabase().cancelPendingMessageSendJobs(threadID) + // Send a leave group message if this is an active closed group + if (recipient.address.isClosedGroup && DatabaseComponent.get(context).groupDatabase().isActive(recipient.address.toGroupString())) { + try { + GroupUtil.doubleDecodeGroupID(recipient.address.toString()).toHexString() + .takeIf(DatabaseComponent.get(context).lokiAPIDatabase()::isClosedGroup) + ?.let { MessageSender.explicitLeave(it, false) } + } catch (_: IOException) { + } } - if (isClosedGroup) { - MessageSender.explicitLeave(groupPublicKey!!, false) + // Delete the conversation + val v2OpenGroup = DatabaseComponent.get(context).lokiThreadDatabase().getOpenGroupChat(threadID) + if (v2OpenGroup != null) { + v2OpenGroup.apply { OpenGroupManager.delete(server, room, context) } + } else { + lifecycleScope.launch(Dispatchers.IO) { + threadDb.deleteConversation(threadID) + } } + // Update the badge count + ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) + // Notify the user + val toastMessage = if (recipient.isGroupRecipient) R.string.MessageRecord_left_group else R.string.activity_home_conversation_deleted_message + Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show() } - // Delete the conversation - val v2OpenGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadID) - if (v2OpenGroup != null) { - OpenGroupManager.delete(v2OpenGroup.server, v2OpenGroup.room, this@HomeActivity) - } else { - lifecycleScope.launch(Dispatchers.IO) { - threadDb.deleteConversation(threadID) - } - } - // Update the badge count - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) - // Notify the user - val toastMessage = if (recipient.isGroupRecipient) R.string.MessageRecord_left_group else R.string.activity_home_conversation_deleted_message - Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show() } + button(R.string.no) } - dialog.setNegativeButton(R.string.no) { _, _ -> - // Do nothing - } - dialog.create().show() } private fun openSettings() { @@ -605,17 +626,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } private fun hideMessageRequests() { - AlertDialog.Builder(this) - .setMessage("Hide message requests?") - .setPositiveButton(R.string.yes) { _, _ -> + showSessionDialog { + text("Hide message requests?") + button(R.string.yes) { textSecurePreferences.setHasHiddenMessageRequests() - setupMessageRequestsBanner() - homeViewModel.tryUpdateChannel() + homeViewModel.tryReload() } - .setNegativeButton(R.string.no) { _, _ -> - // Do nothing - } - .create().show() + button(R.string.no) + } } private fun showNewConversation() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt index 3efa841b54..571adb7358 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeAdapter.kt @@ -1,18 +1,26 @@ package org.thoughtcrime.securesms.home import android.content.Context +import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListUpdateCallback import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_ID -import org.thoughtcrime.securesms.database.model.ThreadRecord +import network.loki.messenger.R +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 listener: ConversationClickListener + private val configFactory: ConfigFactory, + private val listener: ConversationClickListener, + private val showMessageRequests: () -> Unit, + private val hideMessageRequests: () -> Unit, ) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), ListUpdateCallback { companion object { @@ -20,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) + 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) @@ -57,48 +74,55 @@ 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) { - 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 view = ConversationView(context) - view.setOnClickListener { view.thread?.let { listener.onConversationClick(it) } } - view.setOnLongClickListener { - view.thread?.let { listener.onLongConversationClick(it) } + val conversationView = LayoutInflater.from(parent.context).inflate(R.layout.view_conversation, parent, false) as ConversationView + val viewHolder = ConversationViewHolder(conversationView) + viewHolder.view.setOnClickListener { viewHolder.view.thread?.let { listener.onConversationClick(it) } } + viewHolder.view.setOnLongClickListener { + viewHolder.view.thread?.let { listener.onLongConversationClick(it) } true } - ViewHolder(view) + viewHolder } else -> throw Exception("viewType $viewType isn't valid") } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - if (holder is ViewHolder) { - 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 ViewHolder) { + if (holder is ConversationViewHolder) { holder.view.recycle() - } else { - super.onViewRecycled(holder) } } @@ -106,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 ViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view) + 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 fcaf565e0d..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,42 +2,56 @@ 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 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 - val sameCount = oldItem.count == newItem.count - if (!sameCount) return false - val sameUnreads = oldItem.unreadCount == newItem.unreadCount - if (!sameUnreads) return false - val samePinned = oldItem.isPinned == newItem.isPinned - if (!samePinned) return false - val sameRecipientHash = oldItem.recipientHash == newItem.recipientHash - if (!sameRecipientHash) return false - val sameSnippet = oldItem.getDisplayBody(context) == newItem.getDisplayBody(context) - if (!sameSnippet) return false - val sameSendStatus = oldItem.isFailed == newItem.isFailed && oldItem.isDelivered == newItem.isDelivered - && oldItem.isSent == newItem.isSent && oldItem.isPending == newItem.isPending - if (!sameSendStatus) return false + var isSameItem = true - // all same - return true + if (isSameItem) { isSameItem = (oldItem.count == newItem.count) } + if (isSameItem) { isSameItem = (oldItem.unreadCount == newItem.unreadCount) } + if (isSameItem) { isSameItem = (oldItem.isPinned == newItem.isPinned) } + + // The recipient is passed as a reference and changes to recipients update the reference so we + // need to cache the hashCode for the recipient and use that for diffing - unfortunately + // recipient data is also loaded asyncronously which means every thread will refresh at least + // once when the initial recipient data is loaded + if (isSameItem) { isSameItem = (oldItem.initialRecipientHash == newItem.initialRecipientHash) } + + // Note: Two instances of 'SpannableString' may not equate even though their content matches + if (isSameItem) { isSameItem = (oldItem.getDisplayBody(context).toString() == newItem.getDisplayBody(context).toString()) } + + if (isSameItem) { + isSameItem = ( + oldItem.isFailed == newItem.isFailed && + oldItem.isDelivered == newItem.isDelivered && + oldItem.isSent == newItem.isSent && + oldItem.isPending == newItem.isPending && + oldItem.lastSeen == newItem.lastSeen && + configFactory.convoVolatile?.getConversationUnread(newItem) != true && + old.typingThreadIDs.contains(oldItem.threadId) == new.typingThreadIDs.contains(newItem.threadId) + ) + } + + return isSameItem } } \ No newline at end of file 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/PathStatusView.kt b/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt index 947bd89b4e..7ab7bfb508 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/PathStatusView.kt @@ -9,9 +9,14 @@ import android.graphics.Paint import android.util.AttributeSet import android.view.View import androidx.annotation.ColorInt +import androidx.lifecycle.coroutineScope import androidx.localbroadcastmanager.content.LocalBroadcastManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.snode.OnionRequestAPI +import org.thoughtcrime.securesms.conversation.v2.ViewUtil import org.thoughtcrime.securesms.util.getColorWithID import org.thoughtcrime.securesms.util.toPx @@ -29,6 +34,8 @@ class PathStatusView : View { result } + private var updateJob: Job? = null + constructor(context: Context) : super(context) { initialize() } @@ -87,16 +94,21 @@ class PathStatusView : View { private fun handlePathsBuiltEvent() { update() } private fun update() { - if (OnionRequestAPI.paths.isNotEmpty()) { - setBackgroundResource(R.drawable.accent_dot) - val hasPathsColor = context.getColor(R.color.accent_green) - mainColor = hasPathsColor - sessionShadowColor = hasPathsColor - } else { - setBackgroundResource(R.drawable.paths_building_dot) - val pathsBuildingColor = resources.getColorWithID(R.color.paths_building, context.theme) - mainColor = pathsBuildingColor - sessionShadowColor = pathsBuildingColor + if (updateJob?.isActive != true) { // false or null + updateJob = ViewUtil.getActivityLifecycle(this)?.coroutineScope?.launchWhenStarted { + val paths = withContext(Dispatchers.IO) { OnionRequestAPI.paths } + if (paths.isNotEmpty()) { + setBackgroundResource(R.drawable.accent_dot) + val hasPathsColor = context.getColor(R.color.accent_green) + mainColor = hasPathsColor + sessionShadowColor = hasPathsColor + } else { + setBackgroundResource(R.drawable.paths_building_dot) + val pathsBuildingColor = resources.getColorWithID(R.color.paths_building, context.theme) + mainColor = pathsBuildingColor + sessionShadowColor = pathsBuildingColor + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/UserDetailsBottomSheet.kt index f3915abff6..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,9 +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.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.mms.GlideApp -import org.thoughtcrime.securesms.util.UiModeUtilities import javax.inject.Inject @AndroidEntryPoint @@ -36,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" @@ -55,12 +55,12 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { val recipient = Recipient.from(requireContext(), Address.fromSerialized(publicKey), false) val threadRecipient = threadDb.getRecipientForThreadId(threadID) ?: return dismiss() with(binding) { - profilePictureView.root.publicKey = publicKey - profilePictureView.root.glide = GlideApp.with(this@UserDetailsBottomSheet) - profilePictureView.root.isLarge = true - profilePictureView.root.update(recipient) + profilePictureView.publicKey = publicKey + profilePictureView.isLarge = true + profilePictureView.update(recipient) nameTextViewContainer.visibility = View.VISIBLE nameTextViewContainer.setOnClickListener { + if (recipient.isOpenGroupInboxRecipient || recipient.isOpenGroupOutboxRecipient) return@setOnClickListener nameTextViewContainer.visibility = View.INVISIBLE nameEditTextContainer.visibility = View.VISIBLE nicknameEditText.text = null @@ -87,8 +87,14 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { } nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally - publicKeyTextView.isVisible = !threadRecipient.isOpenGroupRecipient && !threadRecipient.isOpenGroupInboxRecipient - messageButton.isVisible = !threadRecipient.isOpenGroupRecipient || IdPrefix.fromValue(publicKey) == IdPrefix.BLINDED + nameEditIcon.isVisible = threadRecipient.isContactRecipient + && !threadRecipient.isOpenGroupInboxRecipient + && !threadRecipient.isOpenGroupOutboxRecipient + + publicKeyTextView.isVisible = !threadRecipient.isCommunityRecipient + && !threadRecipient.isOpenGroupInboxRecipient + && !threadRecipient.isOpenGroupOutboxRecipient + messageButton.isVisible = !threadRecipient.isCommunityRecipient || IdPrefix.fromValue(publicKey)?.isBlinded() == true publicKeyTextView.text = publicKey publicKeyTextView.setOnLongClickListener { val clipboard = @@ -117,8 +123,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() { override fun onStart() { super.onStart() val window = dialog?.window ?: return - val isLightMode = UiModeUtilities.isDayUiMode(requireContext()) - window.setDimAmount(if (isLightMode) 0.1f else 0.75f) + window.setDimAmount(0.6f) } fun saveNickName(recipient: Recipient) = with(binding) { @@ -127,14 +132,15 @@ 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 contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase() - val contact = contactDB.getContactWithSessionID(publicKey) ?: Contact(publicKey) + val storage = MessagingModuleConfiguration.shared.storage + val contact = storage.getContactWithSessionID(publicKey) ?: Contact(publicKey) contact.nickname = newNickName - contactDB.setContact(contact) + storage.setContact(contact) nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally } @@ -142,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/GlobalSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt index fab8bca998..7cf953be24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt @@ -83,22 +83,20 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi override fun onViewRecycled(holder: RecyclerView.ViewHolder) { if (holder is ContentView) { - holder.binding.searchResultProfilePicture.root.recycle() + holder.binding.searchResultProfilePicture.recycle() } } class ContentView(view: View, private val modelCallback: (Model) -> Unit) : RecyclerView.ViewHolder(view) { - val binding = ViewGlobalSearchResultBinding.bind(view).apply { - searchResultProfilePicture.root.glide = GlideApp.with(root) - } + val binding = ViewGlobalSearchResultBinding.bind(view) fun bindPayload(newQuery: String, model: Model) { bindQuery(newQuery, model) } fun bind(query: String, model: Model) { - binding.searchResultProfilePicture.root.recycle() + binding.searchResultProfilePicture.recycle() when (model) { is Model.GroupConversation -> bindModel(query, model) is Model.Contact -> bindModel(query, model) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt index 7603d39224..5371bb71c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -12,6 +12,7 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation +import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages import org.thoughtcrime.securesms.util.DateUtils @@ -76,6 +77,8 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) { } binding.searchResultSubtitle.text = getHighlight(query, membersString) } + is Header, // do nothing for header + is SavedMessages -> Unit // do nothing for saved messages (displays note to self) } } @@ -84,12 +87,12 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? { } fun ContentView.bindModel(query: String?, model: GroupConversation) { - binding.searchResultProfilePicture.root.isVisible = true + binding.searchResultProfilePicture.isVisible = true binding.searchResultSavedMessages.isVisible = false binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup binding.searchResultTimestamp.isVisible = false val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false) - binding.searchResultProfilePicture.root.update(threadRecipient) + binding.searchResultProfilePicture.update(threadRecipient) val nameString = model.groupRecord.title binding.searchResultTitle.text = getHighlight(query, nameString) @@ -105,14 +108,14 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) { } fun ContentView.bindModel(query: String?, model: ContactModel) { - binding.searchResultProfilePicture.root.isVisible = true + binding.searchResultProfilePicture.isVisible = true binding.searchResultSavedMessages.isVisible = false binding.searchResultSubtitle.isVisible = false binding.searchResultTimestamp.isVisible = false binding.searchResultSubtitle.text = null val recipient = Recipient.from(binding.root.context, Address.fromSerialized(model.contact.sessionID), false) - binding.searchResultProfilePicture.root.update(recipient) + binding.searchResultProfilePicture.update(recipient) val nameString = model.contact.getSearchName() binding.searchResultTitle.text = getHighlight(query, nameString) } @@ -121,12 +124,12 @@ fun ContentView.bindModel(model: SavedMessages) { binding.searchResultSubtitle.isVisible = false binding.searchResultTimestamp.isVisible = false binding.searchResultTitle.setText(R.string.note_to_self) - binding.searchResultProfilePicture.root.isVisible = false + binding.searchResultProfilePicture.isVisible = false binding.searchResultSavedMessages.isVisible = true } fun ContentView.bindModel(query: String?, model: Message) { - binding.searchResultProfilePicture.root.isVisible = true + binding.searchResultProfilePicture.isVisible = true binding.searchResultSavedMessages.isVisible = false binding.searchResultTimestamp.isVisible = true // val hasUnreads = model.unread > 0 @@ -134,8 +137,8 @@ fun ContentView.bindModel(query: String?, model: Message) { // if (hasUnreads) { // binding.unreadCountTextView.text = model.unread.toString() // } - binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.receivedTimestampMs) - binding.searchResultProfilePicture.root.update(model.messageResult.conversationRecipient) + binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.sentTimestampMs) + binding.searchResultProfilePicture.update(model.messageResult.conversationRecipient) val textSpannable = SpannableStringBuilder() if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) { // group chat, bind diff --git a/app/src/main/java/org/thoughtcrime/securesms/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/jobmanager/AlarmManagerScheduler.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/AlarmManagerScheduler.java deleted file mode 100644 index 62f2ee6b27..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/AlarmManagerScheduler.java +++ /dev/null @@ -1,67 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager; - -import android.app.AlarmManager; -import android.app.Application; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import androidx.annotation.NonNull; - -import com.annimon.stream.Stream; - -import org.thoughtcrime.securesms.ApplicationContext; -import network.loki.messenger.BuildConfig; -import org.session.libsignal.utilities.Log; - -import java.util.List; -import java.util.UUID; - -/** - * Schedules tasks using the {@link AlarmManager}. - * - * Given that this scheduler is only used when {@link KeepAliveService} is also used (which keeps - * all of the {@link ConstraintObserver}s running), this only needs to schedule future runs in - * situations where all constraints are already met. Otherwise, the {@link ConstraintObserver}s will - * trigger future runs when the constraints are met. - * - * For the same reason, this class also doesn't have to schedule jobs that don't have delays. - * - * Important: Only use on API < 26. - */ -public class AlarmManagerScheduler implements Scheduler { - - private static final String TAG = AlarmManagerScheduler.class.getSimpleName(); - - private final Application application; - - AlarmManagerScheduler(@NonNull Application application) { - this.application = application; - } - - @Override - public void schedule(long delay, @NonNull List<Constraint> constraints) { - if (delay > 0 && Stream.of(constraints).allMatch(Constraint::isMet)) { - setUniqueAlarm(application, System.currentTimeMillis() + delay); - } - } - - private void setUniqueAlarm(@NonNull Context context, long time) { - AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - Intent intent = new Intent(context, RetryReceiver.class); - - intent.setAction(BuildConfig.APPLICATION_ID + UUID.randomUUID().toString()); - alarmManager.set(AlarmManager.RTC_WAKEUP, time, PendingIntent.getBroadcast(context, 0, intent, 0)); - - Log.i(TAG, "Set an alarm to retry a job in " + (time - System.currentTimeMillis()) + " ms."); - } - - public static class RetryReceiver extends BroadcastReceiver { - - @Override - public void onReceive(Context context, Intent intent) { - Log.i(TAG, "Received an alarm to retry a job."); - ApplicationContext.getInstance(context).getJobManager().wakeUp(); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/CompositeScheduler.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/CompositeScheduler.java deleted file mode 100644 index 322366f4f4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/CompositeScheduler.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager; - -import androidx.annotation.NonNull; - -import java.util.Arrays; -import java.util.List; - -class CompositeScheduler implements Scheduler { - - private final List<Scheduler> schedulers; - - CompositeScheduler(@NonNull Scheduler... schedulers) { - this.schedulers = Arrays.asList(schedulers); - } - - @Override - public void schedule(long delay, @NonNull List<Constraint> constraints) { - for (Scheduler scheduler : schedulers) { - scheduler.schedule(delay, constraints); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ConstraintInstantiator.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ConstraintInstantiator.java deleted file mode 100644 index b0a67e3d19..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ConstraintInstantiator.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager; - -import androidx.annotation.NonNull; - -import java.util.HashMap; -import java.util.Map; - -public class ConstraintInstantiator { - - private final Map<String, Constraint.Factory> constraintFactories; - - ConstraintInstantiator(@NonNull Map<String, Constraint.Factory> constraintFactories) { - this.constraintFactories = new HashMap<>(constraintFactories); - } - - public @NonNull Constraint instantiate(@NonNull String constraintFactoryKey) { - if (constraintFactories.containsKey(constraintFactoryKey)) { - return constraintFactories.get(constraintFactoryKey).create(); - } else { - throw new IllegalStateException("Tried to instantiate a constraint with key '" + constraintFactoryKey + "', but no matching factory was found."); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ConstraintObserver.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ConstraintObserver.java deleted file mode 100644 index fd7f4fd43c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ConstraintObserver.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager; - -import androidx.annotation.NonNull; - -public interface ConstraintObserver { - - void register(@NonNull Notifier notifier); - - interface Notifier { - void onConstraintMet(@NonNull String reason); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/DependencyInjector.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/DependencyInjector.java deleted file mode 100644 index c8a266bd87..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/DependencyInjector.java +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright (C) 2014 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ -package org.thoughtcrime.securesms.jobmanager; - -/** - * Interface responsible for injecting dependencies into Jobs. - */ -public interface DependencyInjector { - void injectDependencies(Object object); -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ExecutorFactory.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ExecutorFactory.java deleted file mode 100644 index b0c2b974de..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/ExecutorFactory.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager; - -import androidx.annotation.NonNull; - -import java.util.concurrent.ExecutorService; - -public interface ExecutorFactory { - @NonNull ExecutorService newSingleThreadExecutor(@NonNull String name); -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/InAppScheduler.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/InAppScheduler.java deleted file mode 100644 index b0f314eaa6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/InAppScheduler.java +++ /dev/null @@ -1,49 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager; - -import android.os.Handler; -import android.os.HandlerThread; -import androidx.annotation.NonNull; - -import com.annimon.stream.Stream; - -import org.session.libsignal.utilities.Log; - -import java.util.List; - -/** - * Schedules future runs on an in-app handler. Intended to be used in combination with a persistent - * {@link Scheduler} to improve responsiveness when the app is open. - * - * This should only schedule runs when all constraints are met. Because this only works when the - * app is foregrounded, jobs that don't have their constraints met will be run when the relevant - * {@link ConstraintObserver} is triggered. - * - * Similarly, this does not need to schedule retries with no delay, as this doesn't provide any - * persistence, and other mechanisms will take care of that. - */ -class InAppScheduler implements Scheduler { - - private static final String TAG = InAppScheduler.class.getSimpleName(); - - private final JobManager jobManager; - private final Handler handler; - - InAppScheduler(@NonNull JobManager jobManager) { - HandlerThread handlerThread = new HandlerThread("InAppScheduler"); - handlerThread.start(); - - this.jobManager = jobManager; - this.handler = new Handler(handlerThread.getLooper()); - } - - @Override - public void schedule(long delay, @NonNull List<Constraint> constraints) { - if (delay > 0 && Stream.of(constraints).allMatch(Constraint::isMet)) { - Log.i(TAG, "Scheduling a retry in " + delay + " ms."); - handler.postDelayed(() -> { - Log.i(TAG, "Triggering a job retry."); - jobManager.wakeUp(); - }, delay); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Job.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Job.java deleted file mode 100644 index 990207779d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Job.java +++ /dev/null @@ -1,286 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; - -import org.session.libsession.messaging.utilities.Data; -import org.session.libsignal.utilities.Log; - -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -/** - * A durable unit of work. - * - * Jobs have {@link Parameters} that describe the conditions upon when you'd like them to run, how - * often they should be retried, and how long they should be retried for. - * - * Never rely on a specific instance of this class being run. It can be created and destroyed as the - * job is retried. State that you want to save is persisted to a {@link Data} object in - * {@link #serialize()}. Your job is then recreated using a {@link Factory} that you register in - * {@link JobManager.Configuration.Builder#setJobFactories(Map)}, which is given the saved - * {@link Data} bundle. - * - * @deprecated - * use <a href="https://developer.android.com/reference/androidx/work/WorkManager">WorkManager</a> - * API instead. - */ -public abstract class Job { - - private static final String TAG = Log.tag(Job.class); - - private final Parameters parameters; - - private String id; - private int runAttempt; - private long nextRunAttemptTime; - - protected Context context; - - public Job(@NonNull Parameters parameters) { - this.parameters = parameters; - } - - public final String getId() { - return id; - } - - public final @NonNull Parameters getParameters() { - return parameters; - } - - public final int getRunAttempt() { - return runAttempt; - } - - public final long getNextRunAttemptTime() { - return nextRunAttemptTime; - } - - /** - * This is already called by {@link JobController} during job submission, but if you ever run a - * job without submitting it to the {@link JobManager}, then you'll need to invoke this yourself. - */ - public final void setContext(@NonNull Context context) { - this.context = context; - } - - /** Should only be invoked by {@link JobController} */ - final void setId(@NonNull String id) { - this.id = id; - } - - /** Should only be invoked by {@link JobController} */ - final void setRunAttempt(int runAttempt) { - this.runAttempt = runAttempt; - } - - /** Should only be invoked by {@link JobController} */ - final void setNextRunAttemptTime(long nextRunAttemptTime) { - this.nextRunAttemptTime = nextRunAttemptTime; - } - - @WorkerThread - final void onSubmit() { - Log.i(TAG, JobLogger.format(this, "onSubmit()")); - onAdded(); - } - - /** - * Called when the job is first submitted to the {@link JobManager}. - */ - @WorkerThread - public void onAdded() { - } - - /** - * Called after a job has run and its determined that a retry is required. - */ - @WorkerThread - public void onRetry() { - } - - /** - * Serialize your job state so that it can be recreated in the future. - */ - public abstract @NonNull Data serialize(); - - /** - * Returns the key that can be used to find the relevant factory needed to create your job. - */ - public abstract @NonNull String getFactoryKey(); - - /** - * Called to do your actual work. - */ - @WorkerThread - public abstract @NonNull Result run(); - - /** - * Called when your job has completely failed. - */ - @WorkerThread - public abstract void onCanceled(); - - public interface Factory<T extends Job> { - @NonNull T create(@NonNull Parameters parameters, @NonNull Data data); - } - - public enum Result { - SUCCESS, FAILURE, RETRY - } - - public static final class Parameters { - - public static final int IMMORTAL = -1; - public static final int UNLIMITED = -1; - - private final long createTime; - private final long lifespan; - private final int maxAttempts; - private final long maxBackoff; - private final int maxInstances; - private final String queue; - private final List<String> constraintKeys; - - private Parameters(long createTime, - long lifespan, - int maxAttempts, - long maxBackoff, - int maxInstances, - @Nullable String queue, - @NonNull List<String> constraintKeys) - { - this.createTime = createTime; - this.lifespan = lifespan; - this.maxAttempts = maxAttempts; - this.maxBackoff = maxBackoff; - this.maxInstances = maxInstances; - this.queue = queue; - this.constraintKeys = constraintKeys; - } - - public long getCreateTime() { - return createTime; - } - - public long getLifespan() { - return lifespan; - } - - public int getMaxAttempts() { - return maxAttempts; - } - - public long getMaxBackoff() { - return maxBackoff; - } - - public int getMaxInstances() { - return maxInstances; - } - - public @Nullable String getQueue() { - return queue; - } - - public List<String> getConstraintKeys() { - return constraintKeys; - } - - - public static final class Builder { - - private long createTime = System.currentTimeMillis(); - private long maxBackoff = TimeUnit.SECONDS.toMillis(30); - private long lifespan = IMMORTAL; - private int maxAttempts = 1; - private int maxInstances = UNLIMITED; - private String queue = null; - private List<String> constraintKeys = new LinkedList<>(); - - /** Should only be invoked by {@link JobController} */ - Builder setCreateTime(long createTime) { - this.createTime = createTime; - return this; - } - - /** - * Specify the amount of time this job is allowed to be retried. Defaults to {@link #IMMORTAL}. - */ - public @NonNull Builder setLifespan(long lifespan) { - this.lifespan = lifespan; - return this; - } - - /** - * Specify the maximum number of times you want to attempt this job. Defaults to 1. - */ - public @NonNull Builder setMaxAttempts(int maxAttempts) { - this.maxAttempts = maxAttempts; - return this; - } - - /** - * Specify the longest amount of time to wait between retries. No guarantees that this will - * be respected on API >= 26. - */ - public @NonNull Builder setMaxBackoff(long maxBackoff) { - this.maxBackoff = maxBackoff; - return this; - } - - /** - * Specify the maximum number of instances you'd want of this job at any given time. If - * enqueueing this job would put it over that limit, it will be ignored. - * - * Duplicates are determined by two jobs having the same {@link Job#getFactoryKey()}. - * - * This property is ignored if the job is submitted as part of a {@link JobManager.Chain}. - * - * Defaults to {@link #UNLIMITED}. - */ - public @NonNull Builder setMaxInstances(int maxInstances) { - this.maxInstances = maxInstances; - return this; - } - - /** - * Specify a string representing a queue. All jobs within the same queue are run in a - * serialized fashion -- one after the other, in order of insertion. Failure of a job earlier - * in the queue has no impact on the execution of jobs later in the queue. - */ - public @NonNull Builder setQueue(@Nullable String queue) { - this.queue = queue; - return this; - } - - /** - * Add a constraint via the key that was used to register its factory in - * {@link JobManager.Configuration)}; - */ - public @NonNull Builder addConstraint(@NonNull String constraintKey) { - constraintKeys.add(constraintKey); - return this; - } - - /** - * Set constraints via the key that was used to register its factory in - * {@link JobManager.Configuration)}; - */ - public @NonNull Builder setConstraints(@NonNull List<String> constraintKeys) { - this.constraintKeys.clear(); - this.constraintKeys.addAll(constraintKeys); - return this; - } - - public @NonNull Parameters build() { - return new Parameters(createTime, lifespan, maxAttempts, maxBackoff, maxInstances, queue, constraintKeys); - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java deleted file mode 100644 index 33345a03e1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java +++ /dev/null @@ -1,354 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager; - -import android.app.Application; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; - -import com.annimon.stream.Stream; - -import org.session.libsession.messaging.utilities.Data; -import org.session.libsession.utilities.Debouncer; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec; -import org.thoughtcrime.securesms.jobmanager.persistence.DependencySpec; -import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec; -import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec; -import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Set; -import java.util.UUID; - -/** - * Manages the queue of jobs. This is the only class that should write to {@link JobStorage} to - * ensure consistency. - */ -class JobController { - - private static final String TAG = JobController.class.getSimpleName(); - - private final Application application; - private final JobStorage jobStorage; - private final JobInstantiator jobInstantiator; - private final ConstraintInstantiator constraintInstantiator; - private final Data.Serializer dataSerializer; - private final Scheduler scheduler; - private final Debouncer debouncer; - private final Callback callback; - private final Set<String> runningJobs; - - JobController(@NonNull Application application, - @NonNull JobStorage jobStorage, - @NonNull JobInstantiator jobInstantiator, - @NonNull ConstraintInstantiator constraintInstantiator, - @NonNull Data.Serializer dataSerializer, - @NonNull Scheduler scheduler, - @NonNull Debouncer debouncer, - @NonNull Callback callback) - { - this.application = application; - this.jobStorage = jobStorage; - this.jobInstantiator = jobInstantiator; - this.constraintInstantiator = constraintInstantiator; - this.dataSerializer = dataSerializer; - this.scheduler = scheduler; - this.debouncer = debouncer; - this.callback = callback; - this.runningJobs = new HashSet<>(); - } - - @WorkerThread - synchronized void init() { - jobStorage.init(); - jobStorage.updateAllJobsToBePending(); - notifyAll(); - } - - synchronized void wakeUp() { - notifyAll(); - } - - @WorkerThread - synchronized void submitNewJobChain(@NonNull List<List<Job>> chain) { - chain = Stream.of(chain).filterNot(List::isEmpty).toList(); - - if (chain.isEmpty()) { - Log.w(TAG, "Tried to submit an empty job chain. Skipping."); - return; - } - - if (chainExceedsMaximumInstances(chain)) { - Job solo = chain.get(0).get(0); - Log.w(TAG, JobLogger.format(solo, "Already at the max instance count of " + solo.getParameters().getMaxInstances() + ". Skipping.")); - return; - } - - insertJobChain(chain); - scheduleJobs(chain.get(0)); - triggerOnSubmit(chain); - notifyAll(); - } - - @WorkerThread - synchronized void onRetry(@NonNull Job job) { - int nextRunAttempt = job.getRunAttempt() + 1; - long nextRunAttemptTime = calculateNextRunAttemptTime(System.currentTimeMillis(), nextRunAttempt, job.getParameters().getMaxBackoff()); - - jobStorage.updateJobAfterRetry(job.getId(), false, nextRunAttempt, nextRunAttemptTime); - - List<Constraint> constraints = Stream.of(jobStorage.getConstraintSpecs(job.getId())) - .map(ConstraintSpec::getFactoryKey) - .map(constraintInstantiator::instantiate) - .toList(); - - - long delay = Math.max(0, nextRunAttemptTime - System.currentTimeMillis()); - - Log.i(TAG, JobLogger.format(job, "Scheduling a retry in " + delay + " ms.")); - scheduler.schedule(delay, constraints); - - notifyAll(); - } - - synchronized void onJobFinished(@NonNull Job job) { - runningJobs.remove(job.getId()); - } - - @WorkerThread - synchronized void onSuccess(@NonNull Job job) { - jobStorage.deleteJob(job.getId()); - notifyAll(); - } - - /** - * @return The list of all dependent jobs that should also be failed. - */ - @WorkerThread - synchronized @NonNull List<Job> onFailure(@NonNull Job job) { - List<Job> dependents = Stream.of(jobStorage.getDependencySpecsThatDependOnJob(job.getId())) - .map(DependencySpec::getJobId) - .map(jobStorage::getJobSpec) - .withoutNulls() - .map(jobSpec -> { - List<ConstraintSpec> constraintSpecs = jobStorage.getConstraintSpecs(jobSpec.getId()); - return createJob(jobSpec, constraintSpecs); - }) - .toList(); - - List<Job> all = new ArrayList<>(dependents.size() + 1); - all.add(job); - all.addAll(dependents); - - jobStorage.deleteJobs(Stream.of(all).map(Job::getId).toList()); - - return dependents; - } - - /** - * Retrieves the next job that is eligible for execution. To be 'eligible' means that the job: - * - Has no dependencies - * - Has no unmet constraints - * - * This method will block until a job is available. - * When the job returned from this method has been run, you must call {@link #onJobFinished(Job)}. - */ - @WorkerThread - synchronized @NonNull Job pullNextEligibleJobForExecution() { - try { - Job job; - - while ((job = getNextEligibleJobForExecution()) == null) { - if (runningJobs.isEmpty()) { - debouncer.publish(callback::onEmpty); - } - - wait(); - } - - jobStorage.updateJobRunningState(job.getId(), true); - runningJobs.add(job.getId()); - - return job; - } catch (InterruptedException e) { - Log.e(TAG, "Interrupted."); - throw new AssertionError(e); - } - } - - /** - * Retrieves a string representing the state of the job queue. Intended for debugging. - */ - @WorkerThread - synchronized @NonNull String getDebugInfo() { - List<JobSpec> jobs = jobStorage.getAllJobSpecs(); - List<ConstraintSpec> constraints = jobStorage.getAllConstraintSpecs(); - List<DependencySpec> dependencies = jobStorage.getAllDependencySpecs(); - - StringBuilder info = new StringBuilder(); - - info.append("-- Jobs\n"); - if (!jobs.isEmpty()) { - Stream.of(jobs).forEach(j -> info.append(j.toString()).append('\n')); - } else { - info.append("None\n"); - } - - info.append("\n-- Constraints\n"); - if (!constraints.isEmpty()) { - Stream.of(constraints).forEach(c -> info.append(c.toString()).append('\n')); - } else { - info.append("None\n"); - } - - info.append("\n-- Dependencies\n"); - if (!dependencies.isEmpty()) { - Stream.of(dependencies).forEach(d -> info.append(d.toString()).append('\n')); - } else { - info.append("None\n"); - } - - return info.toString(); - } - - @WorkerThread - private boolean chainExceedsMaximumInstances(@NonNull List<List<Job>> chain) { - if (chain.size() == 1 && chain.get(0).size() == 1) { - Job solo = chain.get(0).get(0); - - if (solo.getParameters().getMaxInstances() != Job.Parameters.UNLIMITED && - jobStorage.getJobInstanceCount(solo.getFactoryKey()) >= solo.getParameters().getMaxInstances()) - { - return true; - } - } - return false; - } - - @WorkerThread - private void triggerOnSubmit(@NonNull List<List<Job>> chain) { - Stream.of(chain) - .forEach(list -> Stream.of(list).forEach(job -> { - job.setContext(application); - job.onSubmit(); - })); - } - - @WorkerThread - private void insertJobChain(@NonNull List<List<Job>> chain) { - List<FullSpec> fullSpecs = new LinkedList<>(); - List<Job> dependsOn = Collections.emptyList(); - - for (List<Job> jobList : chain) { - for (Job job : jobList) { - fullSpecs.add(buildFullSpec(job, dependsOn)); - } - dependsOn = jobList; - } - - jobStorage.insertJobs(fullSpecs); - } - - @WorkerThread - private @NonNull FullSpec buildFullSpec(@NonNull Job job, @NonNull List<Job> dependsOn) { - String id = UUID.randomUUID().toString(); - - job.setId(id); - job.setRunAttempt(0); - - JobSpec jobSpec = new JobSpec(job.getId(), - job.getFactoryKey(), - job.getParameters().getQueue(), - job.getParameters().getCreateTime(), - job.getNextRunAttemptTime(), - job.getRunAttempt(), - job.getParameters().getMaxAttempts(), - job.getParameters().getMaxBackoff(), - job.getParameters().getLifespan(), - job.getParameters().getMaxInstances(), - dataSerializer.serialize(job.serialize()), - false); - - List<ConstraintSpec> constraintSpecs = Stream.of(job.getParameters().getConstraintKeys()) - .map(key -> new ConstraintSpec(jobSpec.getId(), key)) - .toList(); - - List<DependencySpec> dependencySpecs = Stream.of(dependsOn) - .map(depends -> new DependencySpec(job.getId(), depends.getId())) - .toList(); - - return new FullSpec(jobSpec, constraintSpecs, dependencySpecs); - } - - @WorkerThread - private void scheduleJobs(@NonNull List<Job> jobs) { - for (Job job : jobs) { - List<Constraint> constraints = Stream.of(job.getParameters().getConstraintKeys()) - .map(key -> new ConstraintSpec(job.getId(), key)) - .map(ConstraintSpec::getFactoryKey) - .map(constraintInstantiator::instantiate) - .toList(); - - scheduler.schedule(0, constraints); - } - } - - @WorkerThread - private @Nullable Job getNextEligibleJobForExecution() { - List<JobSpec> jobSpecs = jobStorage.getPendingJobsWithNoDependenciesInCreatedOrder(System.currentTimeMillis()); - - for (JobSpec jobSpec : jobSpecs) { - List<ConstraintSpec> constraintSpecs = jobStorage.getConstraintSpecs(jobSpec.getId()); - List<Constraint> constraints = Stream.of(constraintSpecs) - .map(ConstraintSpec::getFactoryKey) - .map(constraintInstantiator::instantiate) - .toList(); - - if (Stream.of(constraints).allMatch(Constraint::isMet)) { - return createJob(jobSpec, constraintSpecs); - } - } - - return null; - } - - private @NonNull Job createJob(@NonNull JobSpec jobSpec, @NonNull List<ConstraintSpec> constraintSpecs) { - Job.Parameters parameters = buildJobParameters(jobSpec, constraintSpecs); - Data data = dataSerializer.deserialize(jobSpec.getSerializedData()); - Job job = jobInstantiator.instantiate(jobSpec.getFactoryKey(), parameters, data); - - job.setId(jobSpec.getId()); - job.setRunAttempt(jobSpec.getRunAttempt()); - job.setNextRunAttemptTime(jobSpec.getNextRunAttemptTime()); - job.setContext(application); - - return job; - } - - private @NonNull Job.Parameters buildJobParameters(@NonNull JobSpec jobSpec, @NonNull List<ConstraintSpec> constraintSpecs) { - return new Job.Parameters.Builder() - .setCreateTime(jobSpec.getCreateTime()) - .setLifespan(jobSpec.getLifespan()) - .setMaxAttempts(jobSpec.getMaxAttempts()) - .setQueue(jobSpec.getQueueKey()) - .setConstraints(Stream.of(constraintSpecs).map(ConstraintSpec::getFactoryKey).toList()) - .build(); - } - - private long calculateNextRunAttemptTime(long currentTime, int nextAttempt, long maxBackoff) { - int boundedAttempt = Math.min(nextAttempt, 30); - long exponentialBackoff = (long) Math.pow(2, boundedAttempt) * 1000; - long actualBackoff = Math.min(exponentialBackoff, maxBackoff); - - return currentTime + actualBackoff; - } - - interface Callback { - void onEmpty(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobInstantiator.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobInstantiator.java deleted file mode 100644 index 6d1527d131..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobInstantiator.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager; - -import androidx.annotation.NonNull; - -import org.session.libsession.messaging.utilities.Data; - -import java.util.HashMap; -import java.util.Map; - -class JobInstantiator { - - private final Map<String, Job.Factory> jobFactories; - - JobInstantiator(@NonNull Map<String, Job.Factory> jobFactories) { - this.jobFactories = new HashMap<>(jobFactories); - } - - public @NonNull Job instantiate(@NonNull String jobFactoryKey, @NonNull Job.Parameters parameters, @NonNull Data data) { - if (jobFactories.containsKey(jobFactoryKey)) { - return jobFactories.get(jobFactoryKey).create(parameters, data); - } else { - throw new IllegalStateException("Tried to instantiate a job with key '" + jobFactoryKey + "', but no matching factory was found."); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobLogger.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobLogger.java deleted file mode 100644 index c35f6dc1ac..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobLogger.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager; - -import androidx.annotation.NonNull; -import android.text.TextUtils; - -public class JobLogger { - - public static String format(@NonNull Job job, @NonNull String event) { - return format(job, "", event); - } - - public static String format(@NonNull Job job, @NonNull String extraTag, @NonNull String event) { - String id = job.getId(); - String tag = TextUtils.isEmpty(extraTag) ? "" : "[" + extraTag + "]"; - long timeSinceSubmission = System.currentTimeMillis() - job.getParameters().getCreateTime(); - int runAttempt = job.getRunAttempt() + 1; - String maxAttempts = job.getParameters().getMaxAttempts() == Job.Parameters.UNLIMITED ? "Unlimited" - : String.valueOf(job.getParameters().getMaxAttempts()); - String lifespan = job.getParameters().getLifespan() == Job.Parameters.IMMORTAL ? "Immortal" - : String.valueOf(job.getParameters().getLifespan()) + " ms"; - return String.format("[%s][%s]%s %s (Time Since Submission: %d ms, Lifespan: %s, Run Attempt: %d/%s)", - id, job.getClass().getSimpleName(), tag, event, timeSinceSubmission, lifespan, runAttempt, maxAttempts); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java deleted file mode 100644 index 5906afd292..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java +++ /dev/null @@ -1,310 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager; - -import android.app.Application; -import android.content.Intent; -import android.os.Build; - -import androidx.annotation.NonNull; - -import org.session.libsession.messaging.utilities.Data; -import org.session.libsession.utilities.Debouncer; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.jobmanager.impl.DefaultExecutorFactory; -import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer; -import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; - -/** - * Allows the scheduling of durable jobs that will be run as early as possible. - */ -public class JobManager implements ConstraintObserver.Notifier { - - private static final String TAG = JobManager.class.getSimpleName(); - - private final ExecutorService executor; - private final JobController jobController; - private final JobRunner[] jobRunners; - - private final Set<EmptyQueueListener> emptyQueueListeners = new CopyOnWriteArraySet<>(); - - public JobManager(@NonNull Application application, @NonNull Configuration configuration) { - this.executor = configuration.getExecutorFactory().newSingleThreadExecutor("JobManager"); - this.jobRunners = new JobRunner[configuration.getJobThreadCount()]; - this.jobController = new JobController(application, - configuration.getJobStorage(), - configuration.getJobInstantiator(), - configuration.getConstraintFactories(), - configuration.getDataSerializer(), - Build.VERSION.SDK_INT < 26 ? new AlarmManagerScheduler(application) - : new CompositeScheduler(new InAppScheduler(this), new JobSchedulerScheduler(application)), - new Debouncer(500), - this::onEmptyQueue); - - executor.execute(() -> { - jobController.init(); - - for (int i = 0; i < jobRunners.length; i++) { - jobRunners[i] = new JobRunner(application, i + 1, jobController); - jobRunners[i].start(); - } - - for (ConstraintObserver constraintObserver : configuration.getConstraintObservers()) { - constraintObserver.register(this); - } - - if (Build.VERSION.SDK_INT < 26) { - application.startService(new Intent(application, KeepAliveService.class)); - } - - wakeUp(); - }); - } - - /** - * Enqueues a single job to be run. - */ - public void add(@NonNull Job job) { - new Chain(this, Collections.singletonList(job)).enqueue(); - } - - /** - * Begins the creation of a job chain with a single job. - * @see Chain - */ - public Chain startChain(@NonNull Job job) { - return new Chain(this, Collections.singletonList(job)); - } - - /** - * Begins the creation of a job chain with a set of jobs that can be run in parallel. - * @see Chain - */ - public Chain startChain(@NonNull List<? extends Job> jobs) { - return new Chain(this, jobs); - } - - /** - * Retrieves a string representing the state of the job queue. Intended for debugging. - */ - public @NonNull String getDebugInfo() { - Future<String> result = executor.submit(jobController::getDebugInfo); - try { - return result.get(); - } catch (ExecutionException | InterruptedException e) { - Log.w(TAG, "Failed to retrieve Job info.", e); - return "Failed to retrieve Job info."; - } - } - - /** - * Adds a listener to that will be notified when the job queue has been drained. - */ - void addOnEmptyQueueListener(@NonNull EmptyQueueListener listener) { - executor.execute(() -> { - emptyQueueListeners.add(listener); - }); - } - - /** - * Removes a listener that was added via {@link #addOnEmptyQueueListener(EmptyQueueListener)}. - */ - void removeOnEmptyQueueListener(@NonNull EmptyQueueListener listener) { - executor.execute(() -> { - emptyQueueListeners.remove(listener); - }); - } - - @Override - public void onConstraintMet(@NonNull String reason) { - Log.i(TAG, "onConstraintMet(" + reason + ")"); - wakeUp(); - } - - /** - * Pokes the system to take another pass at the job queue. - */ - void wakeUp() { - executor.execute(jobController::wakeUp); - } - - private void enqueueChain(@NonNull Chain chain) { - executor.execute(() -> { - jobController.submitNewJobChain(chain.getJobListChain()); - wakeUp(); - }); - } - - private void onEmptyQueue() { - executor.execute(() -> { - for (EmptyQueueListener listener : emptyQueueListeners) { - listener.onQueueEmpty(); - } - }); - } - - public interface EmptyQueueListener { - void onQueueEmpty(); - } - - /** - * Allows enqueuing work that depends on each other. Jobs that appear later in the chain will - * only run after all jobs earlier in the chain have been completed. If a job fails, all jobs - * that occur later in the chain will also be failed. - */ - public static class Chain { - - private final JobManager jobManager; - private final List<List<Job>> jobs; - - private Chain(@NonNull JobManager jobManager, @NonNull List<? extends Job> jobs) { - this.jobManager = jobManager; - this.jobs = new LinkedList<>(); - - this.jobs.add(new ArrayList<>(jobs)); - } - - public Chain then(@NonNull Job job) { - return then(Collections.singletonList(job)); - } - - public Chain then(@NonNull List<Job> jobs) { - if (!jobs.isEmpty()) { - this.jobs.add(new ArrayList<>(jobs)); - } - return this; - } - - public void enqueue() { - jobManager.enqueueChain(this); - } - - private List<List<Job>> getJobListChain() { - return jobs; - } - } - - public static class Configuration { - - private final ExecutorFactory executorFactory; - private final int jobThreadCount; - private final JobInstantiator jobInstantiator; - private final ConstraintInstantiator constraintInstantiator; - private final List<ConstraintObserver> constraintObservers; - private final Data.Serializer dataSerializer; - private final JobStorage jobStorage; - - private Configuration(int jobThreadCount, - @NonNull ExecutorFactory executorFactory, - @NonNull JobInstantiator jobInstantiator, - @NonNull ConstraintInstantiator constraintInstantiator, - @NonNull List<ConstraintObserver> constraintObservers, - @NonNull Data.Serializer dataSerializer, - @NonNull JobStorage jobStorage) - { - this.executorFactory = executorFactory; - this.jobThreadCount = jobThreadCount; - this.jobInstantiator = jobInstantiator; - this.constraintInstantiator = constraintInstantiator; - this.constraintObservers = constraintObservers; - this.dataSerializer = dataSerializer; - this.jobStorage = jobStorage; - } - - int getJobThreadCount() { - return jobThreadCount; - } - - @NonNull ExecutorFactory getExecutorFactory() { - return executorFactory; - } - - @NonNull JobInstantiator getJobInstantiator() { - return jobInstantiator; - } - - @NonNull - ConstraintInstantiator getConstraintFactories() { - return constraintInstantiator; - } - - @NonNull List<ConstraintObserver> getConstraintObservers() { - return constraintObservers; - } - - @NonNull Data.Serializer getDataSerializer() { - return dataSerializer; - } - - @NonNull JobStorage getJobStorage() { - return jobStorage; - } - - - public static class Builder { - - private ExecutorFactory executorFactory = new DefaultExecutorFactory(); - private int jobThreadCount = 1; - private Map<String, Job.Factory> jobFactories = new HashMap<>(); - private Map<String, Constraint.Factory> constraintFactories = new HashMap<>(); - private List<ConstraintObserver> constraintObservers = new ArrayList<>(); - private Data.Serializer dataSerializer = new JsonDataSerializer(); - private JobStorage jobStorage = null; - - public @NonNull Builder setJobThreadCount(int jobThreadCount) { - this.jobThreadCount = jobThreadCount; - return this; - } - - public @NonNull Builder setExecutorFactory(@NonNull ExecutorFactory executorFactory) { - this.executorFactory = executorFactory; - return this; - } - - public @NonNull Builder setJobFactories(@NonNull Map<String, Job.Factory> jobFactories) { - this.jobFactories = jobFactories; - return this; - } - - public @NonNull Builder setConstraintFactories(@NonNull Map<String, Constraint.Factory> constraintFactories) { - this.constraintFactories = constraintFactories; - return this; - } - - public @NonNull Builder setConstraintObservers(@NonNull List<ConstraintObserver> constraintObservers) { - this.constraintObservers = constraintObservers; - return this; - } - - public @NonNull Builder setDataSerializer(@NonNull Data.Serializer dataSerializer) { - this.dataSerializer = dataSerializer; - return this; - } - - public @NonNull Builder setJobStorage(@NonNull JobStorage jobStorage) { - this.jobStorage = jobStorage; - return this; - } - - public @NonNull Configuration build() { - return new Configuration(jobThreadCount, - executorFactory, - new JobInstantiator(jobFactories), - new ConstraintInstantiator(constraintFactories), - new ArrayList<>(constraintObservers), - dataSerializer, - jobStorage); - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobRunner.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobRunner.java deleted file mode 100644 index 6eadf0fd5d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobRunner.java +++ /dev/null @@ -1,110 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager; - -import android.app.Application; -import android.os.PowerManager; -import androidx.annotation.NonNull; - -import com.annimon.stream.Stream; - -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.util.WakeLockUtil; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -class JobRunner extends Thread { - - private static final String TAG = JobRunner.class.getSimpleName(); - - private static long WAKE_LOCK_TIMEOUT = TimeUnit.MINUTES.toMillis(10); - - private final Application application; - private final int id; - private final JobController jobController; - - JobRunner(@NonNull Application application, int id, @NonNull JobController jobController) { - super("JobRunner-" + id); - - this.application = application; - this.id = id; - this.jobController = jobController; - } - - @Override - public synchronized void run() { - while (true) { - Job job = jobController.pullNextEligibleJobForExecution(); - Job.Result result = run(job); - - jobController.onJobFinished(job); - - switch (result) { - case SUCCESS: - jobController.onSuccess(job); - break; - case RETRY: - jobController.onRetry(job); - job.onRetry(); - break; - case FAILURE: - List<Job> dependents = jobController.onFailure(job); - job.onCanceled(); - Stream.of(dependents).forEach(Job::onCanceled); - break; - } - } - } - - private Job.Result run(@NonNull Job job) { - Log.i(TAG, JobLogger.format(job, String.valueOf(id), "Running job.")); - - if (isJobExpired(job)) { - Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Failing after surpassing its lifespan.")); - return Job.Result.FAILURE; - } - - Job.Result result = null; - PowerManager.WakeLock wakeLock = null; - - try { - wakeLock = WakeLockUtil.acquire(application, PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TIMEOUT, job.getId()); - result = job.run(); - } catch (Exception e) { - Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Failing due to an unexpected exception."), e); - return Job.Result.FAILURE; - } finally { - if (wakeLock != null) { - WakeLockUtil.release(wakeLock, job.getId()); - } - } - - printResult(job, result); - - if (result == Job.Result.RETRY && job.getRunAttempt() + 1 >= job.getParameters().getMaxAttempts() && - job.getParameters().getMaxAttempts() != Job.Parameters.UNLIMITED) - { - Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Failing after surpassing its max number of attempts.")); - return Job.Result.FAILURE; - } - - return result; - } - - private boolean isJobExpired(@NonNull Job job) { - long expirationTime = job.getParameters().getCreateTime() + job.getParameters().getLifespan(); - - if (expirationTime < 0) { - expirationTime = Long.MAX_VALUE; - } - - return job.getParameters().getLifespan() != Job.Parameters.IMMORTAL && expirationTime <= System.currentTimeMillis(); - } - - private void printResult(@NonNull Job job, @NonNull Job.Result result) { - if (result == Job.Result.FAILURE) { - Log.w(TAG, JobLogger.format(job, String.valueOf(id), "Job failed.")); - } else { - Log.i(TAG, JobLogger.format(job, String.valueOf(id), "Job finished with result: " + result)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobSchedulerScheduler.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobSchedulerScheduler.java deleted file mode 100644 index 40acbf520b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobSchedulerScheduler.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager; - -import android.app.Application; -import android.app.job.JobInfo; -import android.app.job.JobParameters; -import android.app.job.JobScheduler; -import android.app.job.JobService; -import android.content.ComponentName; -import android.content.Context; -import android.content.SharedPreferences; -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; - -import org.thoughtcrime.securesms.ApplicationContext; -import org.session.libsignal.utilities.Log; - -import java.util.List; - -@RequiresApi(26) -public class JobSchedulerScheduler implements Scheduler { - - private static final String TAG = JobSchedulerScheduler.class.getSimpleName(); - - private static final String PREF_NAME = "JobSchedulerScheduler_prefs"; - private static final String PREF_NEXT_ID = "pref_next_id"; - - private static final int MAX_ID = 75; - - private final Application application; - - JobSchedulerScheduler(@NonNull Application application) { - this.application = application; - } - - @RequiresApi(26) - @Override - public void schedule(long delay, @NonNull List<Constraint> constraints) { - JobInfo.Builder jobInfoBuilder = new JobInfo.Builder(getNextId(), new ComponentName(application, SystemService.class)) - .setMinimumLatency(delay) - .setPersisted(true); - - for (Constraint constraint : constraints) { - constraint.applyToJobInfo(jobInfoBuilder); - } - - Log.i(TAG, "Scheduling a run in " + delay + " ms."); - JobScheduler jobScheduler = application.getSystemService(JobScheduler.class); - jobScheduler.schedule(jobInfoBuilder.build()); - } - - private int getNextId() { - SharedPreferences prefs = application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); - int returnedId = prefs.getInt(PREF_NEXT_ID, 0); - int nextId = returnedId + 1 > MAX_ID ? 0 : returnedId + 1; - - prefs.edit().putInt(PREF_NEXT_ID, nextId).apply(); - - return returnedId; - } - - @RequiresApi(api = 26) - public static class SystemService extends JobService { - - @Override - public boolean onStartJob(JobParameters params) { - Log.d(TAG, "onStartJob()"); - - JobManager jobManager = ApplicationContext.getInstance(getApplicationContext()).getJobManager(); - - jobManager.addOnEmptyQueueListener(new JobManager.EmptyQueueListener() { - @Override - public void onQueueEmpty() { - jobManager.removeOnEmptyQueueListener(this); - jobFinished(params, false); - Log.d(TAG, "jobFinished()"); - } - }); - - jobManager.wakeUp(); - - return true; - } - - @Override - public boolean onStopJob(JobParameters params) { - Log.d(TAG, "onStopJob()"); - return false; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Scheduler.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Scheduler.java deleted file mode 100644 index 194acd39b2..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Scheduler.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager; - -import androidx.annotation.NonNull; - -import java.util.List; - -public interface Scheduler { - void schedule(long delay, @NonNull List<Constraint> constraints); -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/CellServiceConstraint.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/CellServiceConstraint.java deleted file mode 100644 index 6d6fc0499f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/CellServiceConstraint.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager.impl; - -import android.app.Application; -import android.app.job.JobInfo; -import androidx.annotation.NonNull; - -import org.thoughtcrime.securesms.jobmanager.Constraint; -import org.thoughtcrime.securesms.sms.TelephonyServiceState; - -public class CellServiceConstraint implements Constraint { - - public static final String KEY = "CellServiceConstraint"; - - private final Application application; - - public CellServiceConstraint(@NonNull Application application) { - this.application = application; - } - - @Override - public @NonNull String getFactoryKey() { - return KEY; - } - - @Override - public boolean isMet() { - TelephonyServiceState telephonyServiceState = new TelephonyServiceState(); - return telephonyServiceState.isConnected(application); - } - - @Override - public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) { - } - - public static final class Factory implements Constraint.Factory<CellServiceConstraint> { - - private final Application application; - - public Factory(@NonNull Application application) { - this.application = application; - } - - @Override - public CellServiceConstraint create() { - return new CellServiceConstraint(application); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/CellServiceConstraintObserver.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/CellServiceConstraintObserver.java deleted file mode 100644 index fd0971dc57..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/CellServiceConstraintObserver.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager.impl; - -import android.app.Application; -import android.content.Context; -import androidx.annotation.NonNull; -import android.telephony.PhoneStateListener; -import android.telephony.ServiceState; -import android.telephony.TelephonyManager; - -import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; - -public class CellServiceConstraintObserver implements ConstraintObserver { - - private static final String REASON = CellServiceConstraintObserver.class.getSimpleName(); - - private Notifier notifier; - - public CellServiceConstraintObserver(@NonNull Application application) { - TelephonyManager telephonyManager = (TelephonyManager) application.getSystemService(Context.TELEPHONY_SERVICE); - ServiceStateListener serviceStateListener = new ServiceStateListener(); - - telephonyManager.listen(serviceStateListener, PhoneStateListener.LISTEN_SERVICE_STATE); - } - - @Override - public void register(@NonNull Notifier notifier) { - this.notifier = notifier; - } - - private class ServiceStateListener extends PhoneStateListener { - @Override - public void onServiceStateChanged(ServiceState serviceState) { - if (serviceState.getState() == ServiceState.STATE_IN_SERVICE && notifier != null) { - notifier.onConstraintMet(REASON); - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/DefaultExecutorFactory.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/DefaultExecutorFactory.java deleted file mode 100644 index a9d4591009..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/DefaultExecutorFactory.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager.impl; - -import androidx.annotation.NonNull; - -import org.thoughtcrime.securesms.jobmanager.ExecutorFactory; - -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -public class DefaultExecutorFactory implements ExecutorFactory { - @Override - public @NonNull ExecutorService newSingleThreadExecutor(@NonNull String name) { - return Executors.newSingleThreadExecutor(r -> new Thread(r, name)); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkConstraintObserver.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkConstraintObserver.java deleted file mode 100644 index ef4a61c7c5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkConstraintObserver.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager.impl; - -import android.app.Application; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.net.ConnectivityManager; -import androidx.annotation.NonNull; - -import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; - -public class NetworkConstraintObserver implements ConstraintObserver { - - private static final String REASON = NetworkConstraintObserver.class.getSimpleName(); - - private final Application application; - - public NetworkConstraintObserver(Application application) { - this.application = application; - } - - @Override - public void register(@NonNull Notifier notifier) { - application.registerReceiver(new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - NetworkConstraint constraint = new NetworkConstraint.Factory(application).create(); - - if (constraint.isMet()) { - notifier.onConstraintMet(REASON); - } - } - }, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkOrCellServiceConstraint.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkOrCellServiceConstraint.java deleted file mode 100644 index c17931f977..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/NetworkOrCellServiceConstraint.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager.impl; - -import android.app.Application; -import android.app.job.JobInfo; -import androidx.annotation.NonNull; - -import org.thoughtcrime.securesms.jobmanager.Constraint; - -public class NetworkOrCellServiceConstraint implements Constraint { - - public static final String KEY = "NetworkOrCellServiceConstraint"; - - private final NetworkConstraint networkConstraint; - private final CellServiceConstraint serviceConstraint; - - public NetworkOrCellServiceConstraint(@NonNull Application application) { - networkConstraint = new NetworkConstraint.Factory(application).create(); - serviceConstraint = new CellServiceConstraint.Factory(application).create(); - } - - @Override - public @NonNull String getFactoryKey() { - return KEY; - } - - @Override - public boolean isMet() { - return networkConstraint.isMet() || serviceConstraint.isMet(); - } - - @Override - public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) { - } - - public static class Factory implements Constraint.Factory<NetworkOrCellServiceConstraint> { - - private final Application application; - - public Factory(@NonNull Application application) { - this.application = application; - } - - @Override - public NetworkOrCellServiceConstraint create() { - return new NetworkOrCellServiceConstraint(application); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SqlCipherMigrationConstraint.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SqlCipherMigrationConstraint.java deleted file mode 100644 index 32fa84b6f5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SqlCipherMigrationConstraint.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager.impl; - -import android.app.Application; -import android.app.job.JobInfo; -import androidx.annotation.NonNull; - -import org.thoughtcrime.securesms.jobmanager.Constraint; -import org.session.libsession.utilities.TextSecurePreferences; - -public class SqlCipherMigrationConstraint implements Constraint { - - public static final String KEY = "SqlCipherMigrationConstraint"; - - private final Application application; - - private SqlCipherMigrationConstraint(@NonNull Application application) { - this.application = application; - } - - @Override - public boolean isMet() { - return !TextSecurePreferences.getNeedsSqlCipherMigration(application); - } - - @NonNull - @Override - public String getFactoryKey() { - return KEY; - } - - @Override - public void applyToJobInfo(@NonNull JobInfo.Builder jobInfoBuilder) { - } - - public static final class Factory implements Constraint.Factory<SqlCipherMigrationConstraint> { - - private final Application application; - - public Factory(@NonNull Application application) { - this.application = application; - } - - @Override - public SqlCipherMigrationConstraint create() { - return new SqlCipherMigrationConstraint(application); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SqlCipherMigrationConstraintObserver.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SqlCipherMigrationConstraintObserver.java deleted file mode 100644 index 0c9225434d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/SqlCipherMigrationConstraintObserver.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager.impl; - -import androidx.annotation.NonNull; - -import org.greenrobot.eventbus.EventBus; -import org.greenrobot.eventbus.Subscribe; -import org.greenrobot.eventbus.ThreadMode; -import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; - -public class SqlCipherMigrationConstraintObserver implements ConstraintObserver { - - private static final String REASON = SqlCipherMigrationConstraintObserver.class.getSimpleName(); - - private Notifier notifier; - - public SqlCipherMigrationConstraintObserver() { - EventBus.getDefault().register(this); - } - - @Override - public void register(@NonNull Notifier notifier) { - this.notifier = notifier; - } - - @Subscribe(threadMode = ThreadMode.MAIN) - public void onEvent(SqlCipherNeedsMigrationEvent event) { - if (notifier != null) notifier.onConstraintMet(REASON); - } - - public static class SqlCipherNeedsMigrationEvent { - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/ConstraintSpec.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/ConstraintSpec.java deleted file mode 100644 index 1dab10ae56..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/ConstraintSpec.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager.persistence; - -import androidx.annotation.NonNull; - -import java.util.Objects; - -public final class ConstraintSpec { - - private final String jobSpecId; - private final String factoryKey; - - public ConstraintSpec(@NonNull String jobSpecId, @NonNull String factoryKey) { - this.jobSpecId = jobSpecId; - this.factoryKey = factoryKey; - } - - public String getJobSpecId() { - return jobSpecId; - } - - public String getFactoryKey() { - return factoryKey; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - ConstraintSpec that = (ConstraintSpec) o; - return Objects.equals(jobSpecId, that.jobSpecId) && - Objects.equals(factoryKey, that.factoryKey); - } - - @Override - public int hashCode() { - return Objects.hash(jobSpecId, factoryKey); - } - - @Override - public @NonNull String toString() { - return String.format("jobSpecId: %s | factoryKey: %s", jobSpecId, factoryKey); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/DependencySpec.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/DependencySpec.java deleted file mode 100644 index 2faea0485b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/DependencySpec.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager.persistence; - -import androidx.annotation.NonNull; - -import java.util.Objects; - -public final class DependencySpec { - - private final String jobId; - private final String dependsOnJobId; - - public DependencySpec(@NonNull String jobId, @NonNull String dependsOnJobId) { - this.jobId = jobId; - this.dependsOnJobId = dependsOnJobId; - } - - public @NonNull String getJobId() { - return jobId; - } - - public @NonNull String getDependsOnJobId() { - return dependsOnJobId; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - DependencySpec that = (DependencySpec) o; - return Objects.equals(jobId, that.jobId) && - Objects.equals(dependsOnJobId, that.dependsOnJobId); - } - - @Override - public int hashCode() { - return Objects.hash(jobId, dependsOnJobId); - } - - @Override - public @NonNull String toString() { - return String.format("jobSpecId: %s | dependsOnJobSpecId: %s", jobId, dependsOnJobId); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/FullSpec.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/FullSpec.java deleted file mode 100644 index f93c0e64bd..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/FullSpec.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager.persistence; - -import androidx.annotation.NonNull; - -import java.util.List; -import java.util.Objects; - -public final class FullSpec { - - private final JobSpec jobSpec; - private final List<ConstraintSpec> constraintSpecs; - private final List<DependencySpec> dependencySpecs; - - public FullSpec(@NonNull JobSpec jobSpec, - @NonNull List<ConstraintSpec> constraintSpecs, - @NonNull List<DependencySpec> dependencySpecs) - { - this.jobSpec = jobSpec; - this.constraintSpecs = constraintSpecs; - this.dependencySpecs = dependencySpecs; - } - - public @NonNull JobSpec getJobSpec() { - return jobSpec; - } - - public @NonNull List<ConstraintSpec> getConstraintSpecs() { - return constraintSpecs; - } - - public @NonNull List<DependencySpec> getDependencySpecs() { - return dependencySpecs; - } - - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - FullSpec fullSpec = (FullSpec) o; - return Objects.equals(jobSpec, fullSpec.jobSpec) && - Objects.equals(constraintSpecs, fullSpec.constraintSpecs) && - Objects.equals(dependencySpecs, fullSpec.dependencySpecs); - } - - @Override - public int hashCode() { - return Objects.hash(jobSpec, constraintSpecs, dependencySpecs); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.java deleted file mode 100644 index d5f5cd5b3e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.java +++ /dev/null @@ -1,129 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager.persistence; - -import android.annotation.SuppressLint; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.Objects; - -public final class JobSpec { - - private final String id; - private final String factoryKey; - private final String queueKey; - private final long createTime; - private final long nextRunAttemptTime; - private final int runAttempt; - private final int maxAttempts; - private final long maxBackoff; - private final long lifespan; - private final int maxInstances; - private final String serializedData; - private final boolean isRunning; - - public JobSpec(@NonNull String id, - @NonNull String factoryKey, - @Nullable String queueKey, - long createTime, - long nextRunAttemptTime, - int runAttempt, - int maxAttempts, - long maxBackoff, - long lifespan, - int maxInstances, - @NonNull String serializedData, - boolean isRunning) - { - this.id = id; - this.factoryKey = factoryKey; - this.queueKey = queueKey; - this.createTime = createTime; - this.nextRunAttemptTime = nextRunAttemptTime; - this.maxBackoff = maxBackoff; - this.runAttempt = runAttempt; - this.maxAttempts = maxAttempts; - this.lifespan = lifespan; - this.maxInstances = maxInstances; - this.serializedData = serializedData; - this.isRunning = isRunning; - } - - public @NonNull String getId() { - return id; - } - - public @NonNull String getFactoryKey() { - return factoryKey; - } - - public @Nullable String getQueueKey() { - return queueKey; - } - - public long getCreateTime() { - return createTime; - } - - public long getNextRunAttemptTime() { - return nextRunAttemptTime; - } - - public int getRunAttempt() { - return runAttempt; - } - - public int getMaxAttempts() { - return maxAttempts; - } - - public long getMaxBackoff() { - return maxBackoff; - } - - public int getMaxInstances() { - return maxInstances; - } - - public long getLifespan() { - return lifespan; - } - - public @NonNull String getSerializedData() { - return serializedData; - } - - public boolean isRunning() { - return isRunning; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - JobSpec jobSpec = (JobSpec) o; - return createTime == jobSpec.createTime && - nextRunAttemptTime == jobSpec.nextRunAttemptTime && - runAttempt == jobSpec.runAttempt && - maxAttempts == jobSpec.maxAttempts && - maxBackoff == jobSpec.maxBackoff && - lifespan == jobSpec.lifespan && - maxInstances == jobSpec.maxInstances && - isRunning == jobSpec.isRunning && - Objects.equals(id, jobSpec.id) && - Objects.equals(factoryKey, jobSpec.factoryKey) && - Objects.equals(queueKey, jobSpec.queueKey) && - Objects.equals(serializedData, jobSpec.serializedData); - } - - @Override - public int hashCode() { - return Objects.hash(id, factoryKey, queueKey, createTime, nextRunAttemptTime, runAttempt, maxAttempts, maxBackoff, lifespan, maxInstances, serializedData, isRunning); - } - - @SuppressLint("DefaultLocale") - @Override - public @NonNull String toString() { - return String.format("id: %s | factoryKey: %s | queueKey: %s | createTime: %d | nextRunAttemptTime: %d | runAttempt: %d | maxAttempts: %d | maxBackoff: %d | maxInstances: %d | lifespan: %d | isRunning: %b | data: %s", - id, factoryKey, queueKey, createTime, nextRunAttemptTime, runAttempt, maxAttempts, maxBackoff, maxInstances, lifespan, isRunning, serializedData); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobStorage.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobStorage.java deleted file mode 100644 index b7c035ac60..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobStorage.java +++ /dev/null @@ -1,55 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager.persistence; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; - -import java.util.List; - -public interface JobStorage { - - @WorkerThread - void init(); - - @WorkerThread - void insertJobs(@NonNull List<FullSpec> fullSpecs); - - @WorkerThread - @Nullable JobSpec getJobSpec(@NonNull String id); - - @WorkerThread - @NonNull List<JobSpec> getAllJobSpecs(); - - @WorkerThread - @NonNull List<JobSpec> getPendingJobsWithNoDependenciesInCreatedOrder(long currentTime); - - @WorkerThread - int getJobInstanceCount(@NonNull String factoryKey); - - @WorkerThread - void updateJobRunningState(@NonNull String id, boolean isRunning); - - @WorkerThread - void updateJobAfterRetry(@NonNull String id, boolean isRunning, int runAttempt, long nextRunAttemptTime); - - @WorkerThread - void updateAllJobsToBePending(); - - @WorkerThread - void deleteJob(@NonNull String id); - - @WorkerThread - void deleteJobs(@NonNull List<String> ids); - - @WorkerThread - @NonNull List<ConstraintSpec> getConstraintSpecs(@NonNull String jobId); - - @WorkerThread - @NonNull List<ConstraintSpec> getAllConstraintSpecs(); - - @WorkerThread - @NonNull List<DependencySpec> getDependencySpecsThatDependOnJob(@NonNull String jobSpecId); - - @WorkerThread - @NonNull List<DependencySpec> getAllDependencySpecs(); -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarDownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarDownloadJob.java deleted file mode 100644 index 0bf7ea24e4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarDownloadJob.java +++ /dev/null @@ -1,133 +0,0 @@ -package org.thoughtcrime.securesms.jobs; - -import android.graphics.Bitmap; - -import androidx.annotation.NonNull; - -import org.session.libsession.messaging.utilities.Data; -import org.session.libsession.utilities.DownloadUtilities; -import org.session.libsession.utilities.GroupRecord; -import org.session.libsignal.exceptions.InvalidMessageException; -import org.session.libsignal.exceptions.NonSuccessfulResponseCodeException; -import org.session.libsignal.messages.SignalServiceAttachmentPointer; -import org.session.libsignal.streams.AttachmentCipherInputStream; -import org.session.libsignal.utilities.Hex; -import org.session.libsignal.utilities.Log; -import org.session.libsignal.utilities.guava.Optional; -import org.thoughtcrime.securesms.database.GroupDatabase; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.jobmanager.Job; -import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; -import org.thoughtcrime.securesms.mms.AttachmentStreamUriLoader.AttachmentModel; -import org.thoughtcrime.securesms.util.BitmapDecodingException; -import org.thoughtcrime.securesms.util.BitmapUtil; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; - -public class AvatarDownloadJob extends BaseJob { - - public static final String KEY = "AvatarDownloadJob"; - - private static final String TAG = AvatarDownloadJob.class.getSimpleName(); - - private static final int MAX_AVATAR_SIZE = 20 * 1024 * 1024; - - private static final String KEY_GROUP_ID = "group_id"; - - private String groupId; - - public AvatarDownloadJob(@NonNull String groupId) { - this(new Job.Parameters.Builder() - .addConstraint(NetworkConstraint.KEY) - .setMaxAttempts(10) - .build(), - groupId); - } - - private AvatarDownloadJob(@NonNull Job.Parameters parameters, @NonNull String groupId) { - super(parameters); - this.groupId = groupId; - } - - @Override - public @NonNull Data serialize() { - return new Data.Builder().putString(KEY_GROUP_ID, groupId).build(); - } - - @Override - public @NonNull String getFactoryKey() { - return KEY; - } - - @Override - public void onRun() throws IOException { - GroupDatabase database = DatabaseComponent.get(context).groupDatabase(); - Optional<GroupRecord> record = database.getGroup(groupId); - File attachment = null; - - try { - if (record.isPresent()) { - long avatarId = record.get().getAvatarId(); - String contentType = record.get().getAvatarContentType(); - byte[] key = record.get().getAvatarKey(); - String relay = record.get().getRelay(); - Optional<byte[]> digest = Optional.fromNullable(record.get().getAvatarDigest()); - Optional<String> fileName = Optional.absent(); - String url = record.get().getUrl(); - - if (avatarId == -1 || key == null || url.isEmpty()) { - return; - } - - if (digest.isPresent()) { - Log.i(TAG, "Downloading group avatar with digest: " + Hex.toString(digest.get())); - } - - attachment = File.createTempFile("avatar", "tmp", context.getCacheDir()); - attachment.deleteOnExit(); - - SignalServiceAttachmentPointer pointer = new SignalServiceAttachmentPointer(avatarId, contentType, key, Optional.of(0), Optional.absent(), 0, 0, digest, fileName, false, Optional.absent(), url); - - if (pointer.getUrl().isEmpty()) throw new InvalidMessageException("Missing attachment URL."); - DownloadUtilities.downloadFile(attachment, pointer.getUrl()); - - // Assume we're retrieving an attachment for an open group server if the digest is not set - InputStream inputStream; - if (!pointer.getDigest().isPresent()) { - inputStream = new FileInputStream(attachment); - } else { - inputStream = AttachmentCipherInputStream.createForAttachment(attachment, pointer.getSize().or(0), pointer.getKey(), pointer.getDigest().get()); - } - - Bitmap avatar = BitmapUtil.createScaledBitmap(context, new AttachmentModel(attachment, key, 0, digest), 500, 500); - - database.updateProfilePicture(groupId, avatar); - inputStream.close(); - } - } catch (BitmapDecodingException | NonSuccessfulResponseCodeException | InvalidMessageException e) { - Log.w(TAG, e); - } finally { - if (attachment != null) - attachment.delete(); - } - } - - @Override - public void onCanceled() {} - - @Override - public boolean onShouldRetry(@NonNull Exception exception) { - if (exception instanceof IOException) return true; - return false; - } - - public static final class Factory implements Job.Factory<AvatarDownloadJob> { - @Override - public @NonNull AvatarDownloadJob create(@NonNull Parameters parameters, @NonNull Data data) { - return new AvatarDownloadJob(parameters, data.getString(KEY_GROUP_ID)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BaseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/BaseJob.java deleted file mode 100644 index 0c11cc552b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BaseJob.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.thoughtcrime.securesms.jobs; - -import androidx.annotation.NonNull; - -import org.thoughtcrime.securesms.jobmanager.Job; -import org.thoughtcrime.securesms.jobmanager.JobLogger; -import org.session.libsignal.utilities.Log; - -/** - * @deprecated - * use <a href="https://developer.android.com/reference/androidx/work/WorkManager">WorkManager</a> - * API instead. - */ -public abstract class BaseJob extends Job { - - private static final String TAG = BaseJob.class.getSimpleName(); - - public BaseJob(@NonNull Parameters parameters) { - super(parameters); - } - - @Override - public @NonNull Result run() { - try { - onRun(); - return Result.SUCCESS; - } catch (Exception e) { - if (onShouldRetry(e)) { - Log.i(TAG, JobLogger.format(this, "Encountered a retryable exception."), e); - return Result.RETRY; - } else { - Log.w(TAG, JobLogger.format(this, "Encountered a failing exception."), e); - return Result.FAILURE; - } - } - } - - protected abstract void onRun() throws Exception; - - protected abstract boolean onShouldRetry(@NonNull Exception e); -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/FastJobStorage.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/FastJobStorage.java deleted file mode 100644 index 3b50dc2733..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/FastJobStorage.java +++ /dev/null @@ -1,261 +0,0 @@ -package org.thoughtcrime.securesms.jobs; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.annimon.stream.Stream; - -import org.thoughtcrime.securesms.database.JobDatabase; -import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec; -import org.thoughtcrime.securesms.jobmanager.persistence.DependencySpec; -import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec; -import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec; -import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage; - -import org.session.libsession.utilities.Util; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.List; -import java.util.ListIterator; -import java.util.Map; -import java.util.Set; - -public class FastJobStorage implements JobStorage { - - private final JobDatabase jobDatabase; - - private final List<JobSpec> jobs; - private final Map<String, List<ConstraintSpec>> constraintsByJobId; - private final Map<String, List<DependencySpec>> dependenciesByJobId; - - public FastJobStorage(@NonNull JobDatabase jobDatabase) { - this.jobDatabase = jobDatabase; - this.jobs = new ArrayList<>(); - this.constraintsByJobId = new HashMap<>(); - this.dependenciesByJobId = new HashMap<>(); - } - - @Override - public synchronized void init() { - List<JobSpec> jobSpecs = jobDatabase.getAllJobSpecs(); - List<ConstraintSpec> constraintSpecs = jobDatabase.getAllConstraintSpecs(); - List<DependencySpec> dependencySpecs = jobDatabase.getAllDependencySpecs(); - - jobs.addAll(jobSpecs); - - for (ConstraintSpec constraintSpec: constraintSpecs) { - List<ConstraintSpec> jobConstraints = Util.getOrDefault(constraintsByJobId, constraintSpec.getJobSpecId(), new LinkedList<>()); - jobConstraints.add(constraintSpec); - constraintsByJobId.put(constraintSpec.getJobSpecId(), jobConstraints); - } - - for (DependencySpec dependencySpec : dependencySpecs) { - List<DependencySpec> jobDependencies = Util.getOrDefault(dependenciesByJobId, dependencySpec.getJobId(), new LinkedList<>()); - jobDependencies.add(dependencySpec); - dependenciesByJobId.put(dependencySpec.getJobId(), jobDependencies); - } - } - - @Override - public synchronized void insertJobs(@NonNull List<FullSpec> fullSpecs) { - jobDatabase.insertJobs(fullSpecs); - - for (FullSpec fullSpec : fullSpecs) { - jobs.add(fullSpec.getJobSpec()); - constraintsByJobId.put(fullSpec.getJobSpec().getId(), fullSpec.getConstraintSpecs()); - dependenciesByJobId.put(fullSpec.getJobSpec().getId(), fullSpec.getDependencySpecs()); - } - } - - @Override - public synchronized @Nullable JobSpec getJobSpec(@NonNull String id) { - for (JobSpec jobSpec : jobs) { - if (jobSpec.getId().equals(id)) { - return jobSpec; - } - } - return null; - } - - @Override - public synchronized @NonNull List<JobSpec> getAllJobSpecs() { - return new ArrayList<>(jobs); - } - - @Override - public synchronized @NonNull List<JobSpec> getPendingJobsWithNoDependenciesInCreatedOrder(long currentTime) { - return Stream.of(jobs) - .filter(j -> JobManagerFactories.hasFactoryForKey(j.getFactoryKey())) - .filterNot(JobSpec::isRunning) - .filter(this::firstInQueue) - .filter(j -> !dependenciesByJobId.containsKey(j.getId()) || dependenciesByJobId.get(j.getId()).isEmpty()) - .filter(j -> j.getNextRunAttemptTime() <= currentTime) - .sorted((j1, j2) -> Long.compare(j1.getCreateTime(), j2.getCreateTime())) - .toList(); - } - - private boolean firstInQueue(@NonNull JobSpec job) { - if (job.getQueueKey() == null) { - return true; - } - - return Stream.of(jobs) - .filter(j -> Util.equals(j.getQueueKey(), job.getQueueKey())) - .sorted((j1, j2) -> Long.compare(j1.getCreateTime(), j2.getCreateTime())) - .toList() - .get(0) - .equals(job); - } - - @Override - public synchronized int getJobInstanceCount(@NonNull String factoryKey) { - return (int) Stream.of(jobs) - .filter(j -> j.getFactoryKey().equals(factoryKey)) - .count(); - } - - @Override - public synchronized void updateJobRunningState(@NonNull String id, boolean isRunning) { - jobDatabase.updateJobRunningState(id, isRunning); - - ListIterator<JobSpec> iter = jobs.listIterator(); - - while (iter.hasNext()) { - JobSpec existing = iter.next(); - if (existing.getId().equals(id)) { - JobSpec updated = new JobSpec(existing.getId(), - existing.getFactoryKey(), - existing.getQueueKey(), - existing.getCreateTime(), - existing.getNextRunAttemptTime(), - existing.getRunAttempt(), - existing.getMaxAttempts(), - existing.getMaxBackoff(), - existing.getLifespan(), - existing.getMaxInstances(), - existing.getSerializedData(), - isRunning); - iter.set(updated); - } - } - } - - @Override - public synchronized void updateJobAfterRetry(@NonNull String id, boolean isRunning, int runAttempt, long nextRunAttemptTime) { - jobDatabase.updateJobAfterRetry(id, isRunning, runAttempt, nextRunAttemptTime); - - ListIterator<JobSpec> iter = jobs.listIterator(); - - while (iter.hasNext()) { - JobSpec existing = iter.next(); - if (existing.getId().equals(id)) { - JobSpec updated = new JobSpec(existing.getId(), - existing.getFactoryKey(), - existing.getQueueKey(), - existing.getCreateTime(), - nextRunAttemptTime, - runAttempt, - existing.getMaxAttempts(), - existing.getMaxBackoff(), - existing.getLifespan(), - existing.getMaxInstances(), - existing.getSerializedData(), - isRunning); - iter.set(updated); - } - } - } - - @Override - public synchronized void updateAllJobsToBePending() { - jobDatabase.updateAllJobsToBePending(); - - ListIterator<JobSpec> iter = jobs.listIterator(); - - while (iter.hasNext()) { - JobSpec existing = iter.next(); - JobSpec updated = new JobSpec(existing.getId(), - existing.getFactoryKey(), - existing.getQueueKey(), - existing.getCreateTime(), - existing.getNextRunAttemptTime(), - existing.getRunAttempt(), - existing.getMaxAttempts(), - existing.getMaxBackoff(), - existing.getLifespan(), - existing.getMaxInstances(), - existing.getSerializedData(), - false); - iter.set(updated); - } - } - - @Override - public synchronized void deleteJob(@NonNull String jobId) { - deleteJobs(Collections.singletonList(jobId)); - } - - @Override - public synchronized void deleteJobs(@NonNull List<String> jobIds) { - jobDatabase.deleteJobs(jobIds); - - Set<String> deleteIds = new HashSet<>(jobIds); - - Iterator<JobSpec> jobIter = jobs.iterator(); - while (jobIter.hasNext()) { - if (deleteIds.contains(jobIter.next().getId())) { - jobIter.remove(); - } - } - - for (String jobId : jobIds) { - constraintsByJobId.remove(jobId); - dependenciesByJobId.remove(jobId); - - for (Map.Entry<String, List<DependencySpec>> entry : dependenciesByJobId.entrySet()) { - Iterator<DependencySpec> depedencyIter = entry.getValue().iterator(); - - while (depedencyIter.hasNext()) { - if (depedencyIter.next().getDependsOnJobId().equals(jobId)) { - depedencyIter.remove(); - } - } - } - } - } - - @Override - public synchronized @NonNull List<ConstraintSpec> getConstraintSpecs(@NonNull String jobId) { - return Util.getOrDefault(constraintsByJobId, jobId, new LinkedList<>()); - } - - @Override - public synchronized @NonNull List<ConstraintSpec> getAllConstraintSpecs() { - return Stream.of(constraintsByJobId) - .map(Map.Entry::getValue) - .flatMap(Stream::of) - .toList(); - } - - @Override - public synchronized @NonNull List<DependencySpec> getDependencySpecsThatDependOnJob(@NonNull String jobSpecId) { - return Stream.of(dependenciesByJobId.entrySet()) - .map(Map.Entry::getValue) - .flatMap(Stream::of) - .filter(j -> j.getDependsOnJobId().equals(jobSpecId)) - .toList(); - } - - @Override - public @NonNull List<DependencySpec> getAllDependencySpecs() { - return Stream.of(dependenciesByJobId) - .map(Map.Entry::getValue) - .flatMap(Stream::of) - .toList(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java deleted file mode 100644 index ef73325f37..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ /dev/null @@ -1,59 +0,0 @@ -package org.thoughtcrime.securesms.jobs; - -import android.app.Application; - -import androidx.annotation.NonNull; - -import org.thoughtcrime.securesms.jobmanager.Constraint; -import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; -import org.thoughtcrime.securesms.jobmanager.Job; -import org.thoughtcrime.securesms.jobmanager.impl.CellServiceConstraint; -import org.thoughtcrime.securesms.jobmanager.impl.CellServiceConstraintObserver; -import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; -import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraintObserver; -import org.thoughtcrime.securesms.jobmanager.impl.NetworkOrCellServiceConstraint; -import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraint; -import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintObserver; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public final class JobManagerFactories { - - private static Collection<String> factoryKeys = new ArrayList<>(); - - public static Map<String, Job.Factory> getJobFactories(@NonNull Application application) { - HashMap<String, Job.Factory> factoryHashMap = new HashMap<String, Job.Factory>() {{ - put(AvatarDownloadJob.KEY, new AvatarDownloadJob.Factory()); - put(LocalBackupJob.KEY, new LocalBackupJob.Factory()); - put(RetrieveProfileAvatarJob.KEY, new RetrieveProfileAvatarJob.Factory(application)); - put(UpdateApkJob.KEY, new UpdateApkJob.Factory()); - put(PrepareAttachmentAudioExtrasJob.KEY, new PrepareAttachmentAudioExtrasJob.Factory()); - }}; - factoryKeys.addAll(factoryHashMap.keySet()); - return factoryHashMap; - } - - public static Map<String, Constraint.Factory> getConstraintFactories(@NonNull Application application) { - return new HashMap<String, Constraint.Factory>() {{ - put(CellServiceConstraint.KEY, new CellServiceConstraint.Factory(application)); - put(NetworkConstraint.KEY, new NetworkConstraint.Factory(application)); - put(NetworkOrCellServiceConstraint.KEY, new NetworkOrCellServiceConstraint.Factory(application)); - put(SqlCipherMigrationConstraint.KEY, new SqlCipherMigrationConstraint.Factory(application)); - }}; - } - - public static List<ConstraintObserver> getConstraintObservers(@NonNull Application application) { - return Arrays.asList(new CellServiceConstraintObserver(application), - new NetworkConstraintObserver(application), - new SqlCipherMigrationConstraintObserver()); - } - - public static boolean hasFactoryForKey(String factoryKey) { - return factoryKeys.contains(factoryKey); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java deleted file mode 100644 index e5715db263..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java +++ /dev/null @@ -1,83 +0,0 @@ -package org.thoughtcrime.securesms.jobs; - -import androidx.annotation.NonNull; - -import org.session.libsession.messaging.utilities.Data; -import org.session.libsignal.utilities.NoExternalStorageException; -import org.thoughtcrime.securesms.jobmanager.Job; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.database.BackupFileRecord; -import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.service.GenericForegroundService; -import org.thoughtcrime.securesms.util.BackupUtil; - -import java.io.IOException; -import java.util.Collections; - -import network.loki.messenger.R; - -public class LocalBackupJob extends BaseJob { - - public static final String KEY = "LocalBackupJob"; - - private static final String TAG = LocalBackupJob.class.getSimpleName(); - - public LocalBackupJob() { - this(new Job.Parameters.Builder() - .setQueue("__LOCAL_BACKUP__") - .setMaxInstances(1) - .setMaxAttempts(3) - .build()); - } - - private LocalBackupJob(@NonNull Job.Parameters parameters) { - super(parameters); - } - - @Override - public @NonNull - Data serialize() { - return Data.EMPTY; - } - - @Override - public @NonNull String getFactoryKey() { - return KEY; - } - - @Override - public void onRun() throws NoExternalStorageException, IOException { - Log.i(TAG, "Executing backup job..."); - - GenericForegroundService.startForegroundTask(context, - context.getString(R.string.LocalBackupJob_creating_backup), - NotificationChannels.BACKUPS, - R.drawable.ic_launcher_foreground); - - // TODO: Maybe create a new backup icon like ic_signal_backup? - - try { - BackupFileRecord record = BackupUtil.createBackupFile(context); - BackupUtil.deleteAllBackupFiles(context, Collections.singletonList(record)); - - } finally { - GenericForegroundService.stopForegroundTask(context); - } - } - - @Override - public boolean onShouldRetry(@NonNull Exception e) { - return false; - } - - @Override - public void onCanceled() { - } - - public static class Factory implements Job.Factory<LocalBackupJob> { - @Override - public @NonNull LocalBackupJob create(@NonNull Parameters parameters, @NonNull Data data) { - return new LocalBackupJob(parameters); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PrepareAttachmentAudioExtrasJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/PrepareAttachmentAudioExtrasJob.kt deleted file mode 100644 index 69794d41bd..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PrepareAttachmentAudioExtrasJob.kt +++ /dev/null @@ -1,133 +0,0 @@ -package org.thoughtcrime.securesms.jobs - -import android.os.Build -import org.greenrobot.eventbus.EventBus -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.DatabaseAttachmentAudioExtras -import org.session.libsession.messaging.utilities.Data -import org.session.libsession.utilities.DecodedAudio -import org.session.libsession.utilities.InputStreamMediaDataSource -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.jobmanager.Job -import org.thoughtcrime.securesms.jobs.PrepareAttachmentAudioExtrasJob.AudioExtrasUpdatedEvent -import org.thoughtcrime.securesms.mms.PartAuthority -import java.util.* -import java.util.concurrent.TimeUnit - -/** - * Decodes the audio content of the related attachment entry - * and caches the result with [DatabaseAttachmentAudioExtras] data. - * - * It only process attachments with "audio" mime types. - * - * Due to [DecodedAudio] implementation limitations, it only works for API 23+. - * For any lower targets fake data will be generated. - * - * You can subscribe to [AudioExtrasUpdatedEvent] to be notified about the successful result. - */ -//TODO AC: Rewrite to WorkManager API when -// https://github.com/loki-project/session-android/pull/354 is merged. -class PrepareAttachmentAudioExtrasJob : BaseJob { - - companion object { - private const val TAG = "AttachAudioExtrasJob" - - const val KEY = "PrepareAttachmentAudioExtrasJob" - const val DATA_ATTACH_ID = "attachment_id" - - const val VISUAL_RMS_FRAMES = 32 // The amount of values to be computed for the visualization. - } - - private val attachmentId: AttachmentId - - constructor(attachmentId: AttachmentId) : this(Parameters.Builder() - .setQueue(KEY) - .setLifespan(TimeUnit.DAYS.toMillis(1)) - .build(), - attachmentId) - - private constructor(parameters: Parameters, attachmentId: AttachmentId) : super(parameters) { - this.attachmentId = attachmentId - } - - override fun serialize(): Data { - return Data.Builder().putParcelable(DATA_ATTACH_ID, attachmentId).build(); - } - - override fun getFactoryKey(): String { return KEY - } - - override fun onShouldRetry(e: Exception): Boolean { - return false - } - - override fun onCanceled() { } - - override fun onRun() { - Log.v(TAG, "Processing attachment: $attachmentId") - - val attachDb = DatabaseComponent.get(context).attachmentDatabase() - val attachment = attachDb.getAttachment(attachmentId) - - if (attachment == null) { - throw IllegalStateException("Cannot find attachment with the ID $attachmentId") - } - if (!attachment.contentType.startsWith("audio/")) { - throw IllegalStateException("Attachment $attachmentId is not of audio type.") - } - - // Check if the audio extras already exist. - if (attachDb.getAttachmentAudioExtras(attachmentId) != null) return - - fun extractAttachmentRandomSeed(attachment: Attachment): Int { - return when { - attachment.digest != null -> attachment.digest!!.sum() - attachment.fileName != null -> attachment.fileName.hashCode() - else -> attachment.hashCode() - } - } - - fun generateFakeRms(seed: Int, frames: Int = VISUAL_RMS_FRAMES): ByteArray { - return ByteArray(frames).apply { Random(seed.toLong()).nextBytes(this) } - } - - var rmsValues: ByteArray - var totalDurationMs: Long = DatabaseAttachmentAudioExtras.DURATION_UNDEFINED - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - // Due to API version incompatibility, we just display some random waveform for older API. - rmsValues = generateFakeRms(extractAttachmentRandomSeed(attachment)) - } else { - try { - @Suppress("BlockingMethodInNonBlockingContext") - val decodedAudio = PartAuthority.getAttachmentStream(context, attachment.dataUri!!).use { - DecodedAudio.create(InputStreamMediaDataSource(it)) - } - rmsValues = decodedAudio.calculateRms(VISUAL_RMS_FRAMES) - totalDurationMs = (decodedAudio.totalDuration / 1000.0).toLong() - } catch (e: Exception) { - Log.w(TAG, "Failed to decode sample values for the audio attachment \"${attachment.fileName}\".", e) - rmsValues = generateFakeRms(extractAttachmentRandomSeed(attachment)) - } - } - - attachDb.setAttachmentAudioExtras(DatabaseAttachmentAudioExtras( - attachmentId, - rmsValues, - totalDurationMs - )) - - EventBus.getDefault().post(AudioExtrasUpdatedEvent(attachmentId)) - } - - class Factory : Job.Factory<PrepareAttachmentAudioExtrasJob> { - override fun create(parameters: Parameters, data: Data): PrepareAttachmentAudioExtrasJob { - return PrepareAttachmentAudioExtrasJob(parameters, data.getParcelable(DATA_ATTACH_ID, AttachmentId.CREATOR)) - } - } - - /** Gets dispatched once the audio extras have been updated. */ - data class AudioExtrasUpdatedEvent(val attachmentId: AttachmentId) -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java deleted file mode 100644 index 39b7753035..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileAvatarJob.java +++ /dev/null @@ -1,144 +0,0 @@ -package org.thoughtcrime.securesms.jobs; - -import android.app.Application; -import android.text.TextUtils; - -import androidx.annotation.NonNull; - -import org.session.libsession.avatars.AvatarHelper; -import org.session.libsession.messaging.utilities.Data; -import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.DownloadUtilities; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; -import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsignal.exceptions.PushNetworkException; -import org.session.libsignal.streams.ProfileCipherInputStream; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.database.RecipientDatabase; -import org.thoughtcrime.securesms.dependencies.DatabaseComponent; -import org.thoughtcrime.securesms.jobmanager.Job; -import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.security.SecureRandom; -import java.util.concurrent.TimeUnit; - -public class RetrieveProfileAvatarJob extends BaseJob { - - public static final String KEY = "RetrieveProfileAvatarJob"; - - private static final String TAG = RetrieveProfileAvatarJob.class.getSimpleName(); - - private static final int MAX_PROFILE_SIZE_BYTES = 10 * 1024 * 1024; - - private static final String KEY_PROFILE_AVATAR = "profile_avatar"; - private static final String KEY_ADDRESS = "address"; - - - private String profileAvatar; - private Recipient recipient; - - public RetrieveProfileAvatarJob(Recipient recipient, String profileAvatar) { - this(new Job.Parameters.Builder() - .setQueue("RetrieveProfileAvatarJob" + recipient.getAddress().serialize()) - .addConstraint(NetworkConstraint.KEY) - .setLifespan(TimeUnit.HOURS.toMillis(1)) - .setMaxAttempts(2) - .setMaxInstances(1) - .build(), - recipient, - profileAvatar); - } - - private RetrieveProfileAvatarJob(@NonNull Job.Parameters parameters, @NonNull Recipient recipient, String profileAvatar) { - super(parameters); - this.recipient = recipient; - this.profileAvatar = profileAvatar; - } - - @Override - public @NonNull - Data serialize() { - return new Data.Builder() - .putString(KEY_PROFILE_AVATAR, profileAvatar) - .putString(KEY_ADDRESS, recipient.getAddress().serialize()) - .build(); - } - - @Override - public @NonNull String getFactoryKey() { - return KEY; - } - - @Override - public void onRun() throws IOException { - RecipientDatabase database = DatabaseComponent.get(context).recipientDatabase(); - byte[] profileKey = recipient.resolve().getProfileKey(); - - if (profileKey == null || (profileKey.length != 32 && profileKey.length != 16)) { - Log.w(TAG, "Recipient profile key is gone!"); - return; - } - - if (AvatarHelper.avatarFileExists(context, recipient.resolve().getAddress()) && Util.equals(profileAvatar, recipient.resolve().getProfileAvatar())) { - Log.w(TAG, "Already retrieved profile avatar: " + profileAvatar); - return; - } - - if (TextUtils.isEmpty(profileAvatar)) { - Log.w(TAG, "Removing profile avatar for: " + recipient.getAddress().serialize()); - AvatarHelper.delete(context, recipient.getAddress()); - database.setProfileAvatar(recipient, profileAvatar); - return; - } - - File downloadDestination = File.createTempFile("avatar", ".jpg", context.getCacheDir()); - - try { - DownloadUtilities.downloadFile(downloadDestination, profileAvatar); - InputStream avatarStream = new ProfileCipherInputStream(new FileInputStream(downloadDestination), profileKey); - File decryptDestination = File.createTempFile("avatar", ".jpg", context.getCacheDir()); - - Util.copy(avatarStream, new FileOutputStream(decryptDestination)); - decryptDestination.renameTo(AvatarHelper.getAvatarFile(context, recipient.getAddress())); - } finally { - if (downloadDestination != null) downloadDestination.delete(); - } - - if (recipient.isLocalNumber()) { - TextSecurePreferences.setProfileAvatarId(context, new SecureRandom().nextInt()); - } - database.setProfileAvatar(recipient, profileAvatar); - } - - @Override - public boolean onShouldRetry(@NonNull Exception e) { - if (e instanceof PushNetworkException) return true; - return false; - } - - @Override - public void onCanceled() { - } - - public static final class Factory implements Job.Factory<RetrieveProfileAvatarJob> { - - private final Application application; - - public Factory(Application application) { - this.application = application; - } - - @Override - public @NonNull RetrieveProfileAvatarJob create(@NonNull Parameters parameters, @NonNull Data data) { - return new RetrieveProfileAvatarJob(parameters, - Recipient.from(application, Address.fromSerialized(data.getString(KEY_ADDRESS)), true), - data.getString(KEY_PROFILE_AVATAR)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/UpdateApkJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/UpdateApkJob.java deleted file mode 100644 index 5b4ce8d13c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/UpdateApkJob.java +++ /dev/null @@ -1,271 +0,0 @@ -package org.thoughtcrime.securesms.jobs; - - -import android.app.DownloadManager; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.net.Uri; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import org.session.libsession.messaging.utilities.Data; -import org.thoughtcrime.securesms.jobmanager.Job; -import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.service.UpdateApkReadyListener; -import org.session.libsession.utilities.FileUtils; -import org.session.libsignal.utilities.Hex; -import org.session.libsignal.utilities.JsonUtil; -import org.session.libsession.utilities.TextSecurePreferences; - -import java.io.FileInputStream; -import java.io.IOException; -import java.security.MessageDigest; - -import network.loki.messenger.BuildConfig; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; - -public class UpdateApkJob extends BaseJob { - - public static final String KEY = "UpdateApkJob"; - - private static final String TAG = UpdateApkJob.class.getSimpleName(); - - public UpdateApkJob() { - this(new Job.Parameters.Builder() - .setQueue("UpdateApkJob") - .addConstraint(NetworkConstraint.KEY) - .setMaxAttempts(3) - .build()); - } - - private UpdateApkJob(@NonNull Job.Parameters parameters) { - super(parameters); - } - - @Override - public @NonNull - Data serialize() { - return Data.EMPTY; - } - - @Override - public @NonNull String getFactoryKey() { - return KEY; - } - - @Override - public void onRun() throws IOException, PackageManager.NameNotFoundException { - if (!BuildConfig.PLAY_STORE_DISABLED) return; - - Log.i(TAG, "Checking for APK update..."); - - OkHttpClient client = new OkHttpClient(); - Request request = new Request.Builder().url(String.format("%s/latest.json", BuildConfig.NOPLAY_UPDATE_URL)).build(); - - Response response = client.newCall(request).execute(); - - if (!response.isSuccessful()) { - throw new IOException("Bad response: " + response.message()); - } - - UpdateDescriptor updateDescriptor = JsonUtil.fromJson(response.body().string(), UpdateDescriptor.class); - byte[] digest = Hex.fromStringCondensed(updateDescriptor.getDigest()); - - Log.i(TAG, "Got descriptor: " + updateDescriptor); - - if (updateDescriptor.getVersionCode() > getVersionCode()) { - DownloadStatus downloadStatus = getDownloadStatus(updateDescriptor.getUrl(), digest); - - Log.i(TAG, "Download status: " + downloadStatus.getStatus()); - - if (downloadStatus.getStatus() == DownloadStatus.Status.COMPLETE) { - Log.i(TAG, "Download status complete, notifying..."); - handleDownloadNotify(downloadStatus.getDownloadId()); - } else if (downloadStatus.getStatus() == DownloadStatus.Status.MISSING) { - Log.i(TAG, "Download status missing, starting download..."); - handleDownloadStart(updateDescriptor.getUrl(), updateDescriptor.getVersionName(), digest); - } - } - } - - @Override - public boolean onShouldRetry(@NonNull Exception e) { - return e instanceof IOException; - } - - @Override - public void onCanceled() { - Log.w(TAG, "Update check failed"); - } - - private int getVersionCode() throws PackageManager.NameNotFoundException { - PackageManager packageManager = context.getPackageManager(); - PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0); - - return packageInfo.versionCode; - } - - private DownloadStatus getDownloadStatus(String uri, byte[] theirDigest) { - DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - DownloadManager.Query query = new DownloadManager.Query(); - - query.setFilterByStatus(DownloadManager.STATUS_PAUSED | DownloadManager.STATUS_PENDING | DownloadManager.STATUS_RUNNING | DownloadManager.STATUS_SUCCESSFUL); - - long pendingDownloadId = TextSecurePreferences.getUpdateApkDownloadId(context); - byte[] pendingDigest = getPendingDigest(context); - Cursor cursor = downloadManager.query(query); - - try { - DownloadStatus status = new DownloadStatus(DownloadStatus.Status.MISSING, -1); - - while (cursor != null && cursor.moveToNext()) { - int jobStatus = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)); - String jobRemoteUri = cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_URI)); - long downloadId = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID)); - byte[] digest = getDigestForDownloadId(downloadId); - - if (jobRemoteUri != null && jobRemoteUri.equals(uri) && downloadId == pendingDownloadId) { - - if (jobStatus == DownloadManager.STATUS_SUCCESSFUL && - digest != null && pendingDigest != null && - MessageDigest.isEqual(pendingDigest, theirDigest) && - MessageDigest.isEqual(digest, theirDigest)) - { - return new DownloadStatus(DownloadStatus.Status.COMPLETE, downloadId); - } else if (jobStatus != DownloadManager.STATUS_SUCCESSFUL) { - status = new DownloadStatus(DownloadStatus.Status.PENDING, downloadId); - } - } - } - - return status; - } finally { - if (cursor != null) cursor.close(); - } - } - - private void handleDownloadStart(String uri, String versionName, byte[] digest) { - DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - DownloadManager.Request downloadRequest = new DownloadManager.Request(Uri.parse(uri)); - - downloadRequest.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI); - downloadRequest.setTitle("Downloading Signal update"); - downloadRequest.setDescription("Downloading Signal " + versionName); - downloadRequest.setVisibleInDownloadsUi(false); - downloadRequest.setDestinationInExternalFilesDir(context, null, "signal-update.apk"); - downloadRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN); - - long downloadId = downloadManager.enqueue(downloadRequest); - TextSecurePreferences.setUpdateApkDownloadId(context, downloadId); - TextSecurePreferences.setUpdateApkDigest(context, Hex.toStringCondensed(digest)); - } - - private void handleDownloadNotify(long downloadId) { - Intent intent = new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE); - intent.putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId); - - new UpdateApkReadyListener().onReceive(context, intent); - } - - private @Nullable byte[] getDigestForDownloadId(long downloadId) { - try { - DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - FileInputStream fin = new FileInputStream(downloadManager.openDownloadedFile(downloadId).getFileDescriptor()); - byte[] digest = FileUtils.getFileDigest(fin); - - fin.close(); - - return digest; - } catch (IOException e) { - Log.w(TAG, e); - return null; - } - } - - private @Nullable byte[] getPendingDigest(Context context) { - try { - String encodedDigest = TextSecurePreferences.getUpdateApkDigest(context); - - if (encodedDigest == null) return null; - - return Hex.fromStringCondensed(encodedDigest); - } catch (IOException e) { - Log.w(TAG, e); - return null; - } - } - - private static class UpdateDescriptor { - @JsonProperty - private int versionCode; - - @JsonProperty - private String versionName; - - @JsonProperty - private String url; - - @JsonProperty - private String sha256sum; - - - public int getVersionCode() { - return versionCode; - } - - public String getVersionName() { - return versionName; - } - - public String getUrl() { - return url; - } - - public @NonNull String toString() { - return "[" + versionCode + ", " + versionName + ", " + url + "]"; - } - - public String getDigest() { - return sha256sum; - } - } - - private static class DownloadStatus { - enum Status { - PENDING, - COMPLETE, - MISSING - } - - private final Status status; - private final long downloadId; - - DownloadStatus(Status status, long downloadId) { - this.status = status; - this.downloadId = downloadId; - } - - public Status getStatus() { - return status; - } - - public long getDownloadId() { - return downloadId; - } - } - - public static final class Factory implements Job.Factory<UpdateApkJob> { - @Override - public @NonNull UpdateApkJob create(@NonNull Parameters parameters, @NonNull Data data) { - return new UpdateApkJob(parameters); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt index 07da14b090..a85ea525ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/emoji/KeyboardPageSearchView.kt @@ -154,7 +154,7 @@ class KeyboardPageSearchView @JvmOverloads constructor( .setDuration(REVEAL_DURATION) .alpha(0f) .setListener(object : AnimationCompleteListener() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { visibility = INVISIBLE } }) diff --git a/app/src/main/java/org/thoughtcrime/securesms/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/logging/LogFile.java b/app/src/main/java/org/thoughtcrime/securesms/logging/LogFile.java index f0c083ca1d..909f19e08c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logging/LogFile.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logging/LogFile.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.logging; +import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK; + import androidx.annotation.NonNull; import org.session.libsession.utilities.Conversions; @@ -66,15 +68,17 @@ class LogFile { byte[] plaintext = entry.getBytes(); try { - cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(secret, "AES"), new IvParameterSpec(ivBuffer)); + synchronized (CIPHER_LOCK) { + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(secret, "AES"), new IvParameterSpec(ivBuffer)); - int cipherLength = cipher.getOutputSize(plaintext.length); - byte[] ciphertext = ciphertextBuffer.get(cipherLength); - cipherLength = cipher.doFinal(plaintext, 0, plaintext.length, ciphertext); + int cipherLength = cipher.getOutputSize(plaintext.length); + byte[] ciphertext = ciphertextBuffer.get(cipherLength); + cipherLength = cipher.doFinal(plaintext, 0, plaintext.length, ciphertext); - outputStream.write(ivBuffer); - outputStream.write(Conversions.intToByteArray(cipherLength)); - outputStream.write(ciphertext, 0, cipherLength); + outputStream.write(ivBuffer); + outputStream.write(Conversions.intToByteArray(cipherLength)); + outputStream.write(ciphertext, 0, cipherLength); + } outputStream.flush(); } catch (ShortBufferException | InvalidAlgorithmParameterException | InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) { @@ -134,10 +138,11 @@ class LogFile { Util.readFully(inputStream, ciphertext, length); try { - cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(secret, "AES"), new IvParameterSpec(ivBuffer)); - byte[] plaintext = cipher.doFinal(ciphertext, 0, length); - - return new String(plaintext); + synchronized (CIPHER_LOCK) { + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(secret, "AES"), new IvParameterSpec(ivBuffer)); + byte[] plaintext = cipher.doFinal(ciphertext, 0, length); + return new String(plaintext); + } } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { throw new AssertionError(e); } 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/messagerequests/MessageRequestView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt index 9a8d061297..af3d269c6a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestView.kt @@ -34,7 +34,6 @@ class MessageRequestView : LinearLayout { // region Updating fun bind(thread: ThreadRecord, glide: GlideRequests) { this.thread = thread - binding.profilePictureView.root.glide = glide val senderDisplayName = getUserDisplayName(thread.recipient) ?: thread.recipient.address.toString() binding.displayNameTextView.text = senderDisplayName @@ -44,12 +43,12 @@ class MessageRequestView : LinearLayout { binding.snippetTextView.text = snippet post { - binding.profilePictureView.root.update(thread.recipient) + binding.profilePictureView.update(thread.recipient) } } fun recycle() { - binding.profilePictureView.root.recycle() + binding.profilePictureView.recycle() } private fun getUserDisplayName(recipient: Recipient): String? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt index 50ed4628ea..caecbcd87d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsActivity.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.messagerequests -import android.app.AlertDialog import android.content.Intent import android.database.Cursor import android.os.Bundle @@ -20,6 +19,7 @@ import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.push import javax.inject.Inject @@ -49,7 +49,7 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat adapter.glide = glide binding.recyclerView.adapter = adapter - binding.clearAllMessageRequestsButton.setOnClickListener { deleteAllAndBlock() } + binding.clearAllMessageRequestsButton.setOnClickListener { deleteAll() } } override fun onResume() { @@ -77,34 +77,34 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat } override fun onBlockConversationClick(thread: ThreadRecord) { - val dialog = AlertDialog.Builder(this) - dialog.setTitle(R.string.RecipientPreferenceActivity_block_this_contact_question) - .setMessage(R.string.message_requests_block_message) - .setPositiveButton(R.string.recipient_preferences__block) { _, _ -> - viewModel.blockMessageRequest(thread) - LoaderManager.getInstance(this).restartLoader(0, null, this) - } - .setNegativeButton(R.string.no) { _, _ -> - // Do nothing - } - dialog.create().show() + fun doBlock() { + viewModel.blockMessageRequest(thread) + LoaderManager.getInstance(this).restartLoader(0, null, this) + } + + showSessionDialog { + title(R.string.RecipientPreferenceActivity_block_this_contact_question) + text(R.string.message_requests_block_message) + button(R.string.recipient_preferences__block) { doBlock() } + button(R.string.no) + } } override fun onDeleteConversationClick(thread: ThreadRecord) { - val dialog = AlertDialog.Builder(this) - dialog.setTitle(R.string.decline) - .setMessage(resources.getString(R.string.message_requests_decline_message)) - .setPositiveButton(R.string.decline) { _,_ -> - viewModel.deleteMessageRequest(thread) - LoaderManager.getInstance(this).restartLoader(0, null, this) - lifecycleScope.launch(Dispatchers.IO) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity) - } + fun doDecline() { + viewModel.deleteMessageRequest(thread) + LoaderManager.getInstance(this).restartLoader(0, null, this) + lifecycleScope.launch(Dispatchers.IO) { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity) } - .setNegativeButton(R.string.no) { _, _ -> - // Do nothing - } - dialog.create().show() + } + + showSessionDialog { + title(R.string.decline) + text(resources.getString(R.string.message_requests_decline_message)) + button(R.string.decline) { doDecline() } + button(R.string.no) + } } private fun updateEmptyState() { @@ -113,19 +113,19 @@ class MessageRequestsActivity : PassphraseRequiredActionBarActivity(), Conversat binding.clearAllMessageRequestsButton.isVisible = threadCount != 0 } - private fun deleteAllAndBlock() { - val dialog = AlertDialog.Builder(this) - dialog.setMessage(resources.getString(R.string.message_requests_clear_all_message)) - dialog.setPositiveButton(R.string.yes) { _, _ -> - viewModel.clearAllMessageRequests() + private fun deleteAll() { + fun doDeleteAllAndBlock() { + viewModel.clearAllMessageRequests(false) LoaderManager.getInstance(this).restartLoader(0, null, this) lifecycleScope.launch(Dispatchers.IO) { ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@MessageRequestsActivity) } } - dialog.setNegativeButton(R.string.no) { _, _ -> - // Do nothing + + showSessionDialog { + text(resources.getString(R.string.message_requests_clear_all_message)) + button(R.string.yes) { doDeleteAllAndBlock() } + button(R.string.no) } - dialog.create().show() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt index fac0a402e2..10142cc8fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsAdapter.kt @@ -14,7 +14,6 @@ import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.mms.GlideRequests -import org.thoughtcrime.securesms.util.forceShowIcon class MessageRequestsAdapter( context: Context, @@ -49,6 +48,7 @@ class MessageRequestsAdapter( private fun showPopupMenu(view: MessageRequestView) { val popupMenu = PopupMenu(ContextThemeWrapper(context, R.style.PopupMenu_MessageRequests), view) popupMenu.menuInflater.inflate(R.menu.menu_message_request, popupMenu.menu) + popupMenu.menu.findItem(R.id.menu_block_message_request)?.isVisible = !view.thread!!.recipient.isOpenGroupInboxRecipient popupMenu.setOnMenuItemClickListener { menuItem -> if (menuItem.itemId == R.id.menu_delete_message_request) { listener.onDeleteConversationClick(view.thread!!) @@ -64,7 +64,7 @@ class MessageRequestsAdapter( item.iconTintList = ColorStateList.valueOf(context.getColor(R.color.destructive)) item.title = s } - popupMenu.forceShowIcon() + popupMenu.setForceShowIcon(true) popupMenu.show() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt index 2f448932dd..a3a7caf8d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModel.kt @@ -25,8 +25,8 @@ class MessageRequestsViewModel @Inject constructor( repository.deleteMessageRequest(thread) } - fun clearAllMessageRequests() = viewModelScope.launch { - repository.clearAllMessageRequests() + fun clearAllMessageRequests(block: Boolean) = viewModelScope.launch { + repository.clearAllMessageRequests(block) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java index 179c28bc3c..22af450aa8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java @@ -21,26 +21,26 @@ public class PushMediaConstraints extends MediaConstraints { @Override public int getImageMaxSize(Context context) { - return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier); + return FileServerApi.maxFileSize; } @Override public int getGifMaxSize(Context context) { - return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier); + return FileServerApi.maxFileSize; } @Override public int getVideoMaxSize(Context context) { - return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier); + return FileServerApi.maxFileSize; } @Override public int getAudioMaxSize(Context context) { - return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier); + return FileServerApi.maxFileSize; } @Override public int getDocumentMaxSize(Context context) { - return (int) (((double) FileServerApi.maxFileSize) / FileServerApi.fileSizeORMultiplier); + return FileServerApi.maxFileSize; } } 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/mms/SlideDeck.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java index 02ccf85518..6db38e6bc5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideDeck.java @@ -17,17 +17,19 @@ package org.thoughtcrime.securesms.mms; import android.content.Context; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.annimon.stream.Stream; import org.session.libsession.messaging.sending_receiving.attachments.Attachment; -import org.thoughtcrime.securesms.util.MediaUtil; import org.session.libsignal.utilities.guava.Optional; +import org.thoughtcrime.securesms.util.MediaUtil; import java.util.LinkedList; import java.util.List; +import java.util.Objects; public class SlideDeck { @@ -138,4 +140,17 @@ public class SlideDeck { return null; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SlideDeck slideDeck = (SlideDeck) o; + return Objects.equals(slides, slideDeck.slides); + } + + @Override + public int hashCode() { + return Objects.hash(slides); + } } 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 884d6d7bed..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,21 +26,25 @@ 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.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 */ @@ -82,12 +86,16 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver { VisibleMessage message = new VisibleMessage(); message.setText(responseText.toString()); - message.setSentTimestamp(System.currentTimeMillis()); + 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) { @@ -95,8 +103,8 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver { } } else { Log.w("AndroidAutoReplyReceiver", "Sending regular message "); - OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient); - DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(replyThreadId, reply, false, System.currentTimeMillis(), null, true); + OutgoingTextMessage reply = OutgoingTextMessage.from(message, recipient, expiresInMillis, expireStartedAt); + DatabaseComponent.get(context).smsDatabase().insertMessageOutbox(replyThreadId, reply, false, SnodeAPI.getNowWithOffset(), null, true); } List<MarkedMessageInfo> messageIds = DatabaseComponent.get(context).threadDatabase().setRead(replyThreadId, true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt index 48a5725521..e583fb0ca5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -4,9 +4,11 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import androidx.work.Constraints +import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.Worker import androidx.work.WorkerParameters @@ -21,19 +23,35 @@ import org.session.libsession.messaging.sending_receiving.pollers.OpenGroupPolle import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.recover import org.thoughtcrime.securesms.dependencies.DatabaseComponent import java.util.concurrent.TimeUnit class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Worker(context, params) { + enum class Targets { + DMS, CLOSED_GROUPS, OPEN_GROUPS + } companion object { const val TAG = "BackgroundPollWorker" + const val INITIAL_SCHEDULE_TIME = "INITIAL_SCHEDULE_TIME" + const val REQUEST_TARGETS = "REQUEST_TARGETS" @JvmStatic - fun schedulePeriodic(context: Context) { + fun schedulePeriodic(context: Context) = schedulePeriodic(context, targets = Targets.values()) + + @JvmStatic + fun schedulePeriodic(context: Context, targets: Array<Targets>) { Log.v(TAG, "Scheduling periodic work.") - val builder = PeriodicWorkRequestBuilder<BackgroundPollWorker>(15, TimeUnit.MINUTES) + val durationMinutes: Long = 15 + val builder = PeriodicWorkRequestBuilder<BackgroundPollWorker>(durationMinutes, TimeUnit.MINUTES) builder.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + + val dataBuilder = Data.Builder() + dataBuilder.putLong(INITIAL_SCHEDULE_TIME, System.currentTimeMillis() + (durationMinutes * 60 * 1000)) + dataBuilder.putStringArray(REQUEST_TARGETS, targets.map { it.name }.toTypedArray()) + builder.setInputData(dataBuilder.build()) + val workRequest = builder.build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( TAG, @@ -41,49 +59,111 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor workRequest ) } + + @JvmStatic + fun scheduleOnce(context: Context, targets: Array<Targets> = Targets.values()) { + Log.v(TAG, "Scheduling single run.") + val builder = OneTimeWorkRequestBuilder<BackgroundPollWorker>() + builder.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + + val dataBuilder = Data.Builder() + dataBuilder.putStringArray(REQUEST_TARGETS, targets.map { it.name }.toTypedArray()) + builder.setInputData(dataBuilder.build()) + + val workRequest = builder.build() + WorkManager.getInstance(context).enqueue(workRequest) + } } override fun doWork(): Result { - if (TextSecurePreferences.getLocalNumber(context) == null) { + if (TextSecurePreferences.getLocalNumber(context) == null || !TextSecurePreferences.hasSeenWelcomeScreen(context)) { Log.v(TAG, "User not registered yet.") return Result.failure() } + // If this is a scheduled run and it is happening before the initial scheduled time (as + // periodic background tasks run immediately when scheduled) then don't actually do anything + // because this might slow requests on initial startup or triggered by PNs + val initialScheduleTime = inputData.getLong(INITIAL_SCHEDULE_TIME, -1) + + if (initialScheduleTime != -1L && System.currentTimeMillis() < (initialScheduleTime - (60 * 1000))) { + Log.v(TAG, "Skipping initial run.") + return Result.success() + } + + // Retrieve the desired targets (defaulting to all if not provided or empty) + val requestTargets: List<Targets> = (inputData.getStringArray(REQUEST_TARGETS) ?: emptyArray()) + .map { + try { Targets.valueOf(it) } + catch(e: Exception) { null } + } + .filterNotNull() + .ifEmpty { Targets.values().toList() } + try { - Log.v(TAG, "Performing background poll.") + Log.v(TAG, "Performing background poll for ${requestTargets.joinToString { it.name }}.") val promises = mutableListOf<Promise<Unit, Exception>>() // DMs - val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! - val dmsPromise = SnodeAPI.getMessages(userPublicKey).bind { envelopes -> - val params = envelopes.map { (envelope, serverHash) -> - // FIXME: Using a job here seems like a bad idea... - MessageReceiveParameters(envelope.toByteArray(), serverHash, null) + var dmsPromise: Promise<Unit, Exception> = Promise.ofSuccess(Unit) + + if (requestTargets.contains(Targets.DMS)) { + val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! + dmsPromise = SnodeAPI.getMessages(userPublicKey).bind { envelopes -> + val params = envelopes.map { (envelope, serverHash) -> + // FIXME: Using a job here seems like a bad idea... + MessageReceiveParameters(envelope.toByteArray(), serverHash, null) + } + BatchMessageReceiveJob(params).executeAsync("background") } - BatchMessageReceiveJob(params).executeAsync() + promises.add(dmsPromise) } - promises.add(dmsPromise) // Closed groups - val closedGroupPoller = ClosedGroupPollerV2() // Intentionally don't use shared - val storage = MessagingModuleConfiguration.shared.storage - val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys() - allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) } + if (requestTargets.contains(Targets.CLOSED_GROUPS)) { + val closedGroupPoller = ClosedGroupPollerV2() // Intentionally don't use shared + val storage = MessagingModuleConfiguration.shared.storage + val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys() + allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) } + } // Open Groups - val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() - val openGroups = threadDB.getAllOpenGroups() - val openGroupServers = openGroups.map { it.value.server }.toSet() + var ogPollError: Exception? = null - for (server in openGroupServers) { - val poller = OpenGroupPoller(server, null) - poller.hasStarted = true - promises.add(poller.poll()) + if (requestTargets.contains(Targets.OPEN_GROUPS)) { + val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() + val openGroups = threadDB.getAllOpenGroups() + val openGroupServers = openGroups.map { it.value.server }.toSet() + + for (server in openGroupServers) { + val poller = OpenGroupPoller(server, null) + poller.hasStarted = true + + // If one of the open group pollers fails we don't want it to cancel the DM + // poller so just hold on to the error for later + promises.add( + poller.poll().recover { + if (dmsPromise.isDone()) { + throw it + } + + ogPollError = it + } + ) + } } // Wait until all the promises are resolved all(promises).get() + // If the Open Group pollers threw an exception then re-throw it here (now that + // the DM promise has completed) + val localOgPollException = ogPollError + + if (localOgPollException != null) { + throw localOgPollException + } + return Result.success() } catch (exception: Exception) { Log.e(TAG, "Background poll failed due to error: ${exception.message}.", exception) 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 55a4fcd293..b281e0798b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -40,11 +40,11 @@ import com.annimon.stream.Optional; import com.annimon.stream.Stream; import com.goterl.lazysodium.utils.KeyPair; -import org.session.libsession.messaging.MessagingModuleConfiguration; import org.session.libsession.messaging.open_groups.OpenGroup; import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; import org.session.libsession.messaging.utilities.SessionId; import org.session.libsession.messaging.utilities.SodiumUtilities; +import org.session.libsession.snode.SnodeAPI; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Contact; import org.session.libsession.utilities.ServiceUtil; @@ -54,13 +54,12 @@ 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; import org.thoughtcrime.securesms.crypto.KeyPairUtilities; import org.thoughtcrime.securesms.database.LokiThreadDatabase; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; @@ -138,7 +137,7 @@ public class DefaultMessageNotifier implements MessageNotifier { Intent intent = new Intent(context, ConversationActivityV2.class); intent.putExtra(ConversationActivityV2.ADDRESS, recipient.getAddress()); intent.putExtra(ConversationActivityV2.THREAD_ID, threadId); - intent.setData((Uri.parse("custom://" + System.currentTimeMillis()))); + intent.setData((Uri.parse("custom://" + SnodeAPI.getNowWithOffset()))); FailedNotificationBuilder builder = new FailedNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context), intent); ((NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE)) @@ -160,8 +159,9 @@ public class DefaultMessageNotifier implements MessageNotifier { executor.cancel(); } - private void cancelActiveNotifications(@NonNull Context context) { + private boolean cancelActiveNotifications(@NonNull Context context) { NotificationManager notifications = ServiceUtil.getNotificationManager(context); + boolean hasNotifications = notifications.getActiveNotifications().length > 0; notifications.cancel(SUMMARY_NOTIFICATION_ID); try { @@ -175,6 +175,7 @@ public class DefaultMessageNotifier implements MessageNotifier { Log.w(TAG, e); notifications.cancelAll(); } + return hasNotifications; } private void cancelOrphanedNotifications(@NonNull Context context, NotificationState notificationState) { @@ -240,10 +241,6 @@ public class DefaultMessageNotifier implements MessageNotifier { !(recipient.isApproved() || threads.getLastSeenAndHasSent(threadId).second())) { TextSecurePreferences.removeHasHiddenMessageRequests(context); } - if (isVisible && recipient != null) { - List<MarkedMessageInfo> messageIds = threads.setRead(threadId, false); - if (SessionMetaProtocol.shouldSendReadReceipt(recipient)) { MarkReadReceiver.process(context, messageIds); } - } if (!TextSecurePreferences.isNotificationsEnabled(context) || (recipient != null && recipient.isMuted())) @@ -251,11 +248,21 @@ public class DefaultMessageNotifier implements MessageNotifier { return; } - if (!isVisible && !homeScreenVisible) { + if ((!isVisible && !homeScreenVisible) || hasExistingNotifications(context)) { updateNotification(context, signal, 0); } } + private boolean hasExistingNotifications(Context context) { + NotificationManager notifications = ServiceUtil.getNotificationManager(context); + try { + StatusBarNotification[] activeNotifications = notifications.getActiveNotifications(); + return activeNotifications.length > 0; + } catch (Exception e) { + return false; + } + } + @Override public void updateNotification(@NonNull Context context, boolean signal, int reminderCount) { @@ -267,8 +274,8 @@ public class DefaultMessageNotifier implements MessageNotifier { if ((telcoCursor == null || telcoCursor.isAfterLast()) || !TextSecurePreferences.hasSeenWelcomeScreen(context)) { - cancelActiveNotifications(context); updateBadge(context, 0); + cancelActiveNotifications(context); clearReminder(context); return; } @@ -342,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); @@ -453,8 +466,7 @@ public class DefaultMessageNotifier implements MessageNotifier { NotificationState notificationState = new NotificationState(); MmsSmsDatabase.Reader reader = DatabaseComponent.get(context).mmsSmsDatabase().readerFor(cursor); ThreadDatabase threadDatabase = DatabaseComponent.get(context).threadDatabase(); - LokiThreadDatabase lokiThreadDatabase= DatabaseComponent.get(context).lokiThreadDatabase(); - KeyPair edKeyPair = MessagingModuleConfiguration.getShared().getGetUserED25519KeyPair().invoke(); + MessageRecord record; Map<Long, String> cache = new HashMap<Long, String>(); @@ -575,7 +587,7 @@ public class DefaultMessageNotifier implements MessageNotifier { Intent alarmIntent = new Intent(ReminderReceiver.REMINDER_ACTION); alarmIntent.putExtra("reminder_count", count); - PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT); + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE); long timeout = TimeUnit.MINUTES.toMillis(2); alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + timeout, pendingIntent); @@ -584,7 +596,7 @@ public class DefaultMessageNotifier implements MessageNotifier { @Override public void clearReminder(Context context) { Intent alarmIntent = new Intent(ReminderReceiver.REMINDER_ACTION); - PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT); + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE); AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); alarmManager.cancel(pendingIntent); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/FailedNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/FailedNotificationBuilder.java index 1ffd74be63..dc0e52abc6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/FailedNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/FailedNotificationBuilder.java @@ -5,9 +5,10 @@ import android.content.Context; import android.content.Intent; import android.graphics.BitmapFactory; -import network.loki.messenger.R; -import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.NotificationPrivacyPreference; +import org.session.libsession.utilities.recipients.Recipient; + +import network.loki.messenger.R; public class FailedNotificationBuilder extends AbstractNotificationBuilder { @@ -20,7 +21,7 @@ public class FailedNotificationBuilder extends AbstractNotificationBuilder { setContentTitle(context.getString(R.string.MessageNotifier_message_delivery_failed)); setContentText(context.getString(R.string.MessageNotifier_failed_to_deliver_message)); setTicker(context.getString(R.string.MessageNotifier_error_delivering_message)); - setContentIntent(PendingIntent.getActivity(context, 0, intent, 0)); + setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)); setAutoCancel(true); setAlarms(null, Recipient.VibrateState.DEFAULT); setChannelId(NotificationChannels.FAILURES); 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 f1ec6d1887..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java +++ /dev/null @@ -1,104 +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.messaging.messages.control.ReadReceipt; -import org.session.libsession.messaging.sending_receiving.MessageSender; -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.LinkedList; -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) { - List<MarkedMessageInfo> messageIdsCollection = new LinkedList<>(); - - for (long threadId : threadIds) { - Log.i(TAG, "Marking as read: " + threadId); - List<MarkedMessageInfo> messageIds = DatabaseComponent.get(context).threadDatabase().setRead(threadId, true); - messageIdsCollection.addAll(messageIds); - } - - process(context, messageIdsCollection); - - ApplicationContext.getInstance(context).messageNotifier.updateNotification(context); - - 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(System.currentTimeMillis()); - 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 81332e87d9..cad3b6f6c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MultipleRecipientNotificationBuilder.java @@ -34,7 +34,7 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu setColor(context.getResources().getColor(R.color.textsecure_primary)); setSmallIcon(R.drawable.ic_notification); setContentTitle(context.getString(R.string.app_name)); - setContentIntent(PendingIntent.getActivity(context, 0, new Intent(context, HomeActivity.class), 0)); + setContentIntent(PendingIntent.getActivity(context, 0, new Intent(context, HomeActivity.class), PendingIntent.FLAG_IMMUTABLE)); setCategory(NotificationCompat.CATEGORY_MESSAGE); setGroupSummary(true); @@ -52,8 +52,8 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu public void setMostRecentSender(Recipient recipient, Recipient threadRecipient) { String displayName = recipient.toShortString(); - if (threadRecipient.isOpenGroupRecipient()) { - displayName = getOpenGroupDisplayName(recipient); + if (threadRecipient.isGroupRecipient()) { + displayName = getGroupDisplayName(recipient, threadRecipient.isCommunityRecipient()); } if (privacy.isDisplayContact()) { setContentText(context.getString(R.string.MessageNotifier_most_recent_from_s, displayName)); @@ -78,8 +78,8 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu public void addMessageBody(@NonNull Recipient sender, Recipient threadRecipient, @Nullable CharSequence body) { String displayName = sender.toShortString(); - if (threadRecipient.isOpenGroupRecipient()) { - displayName = getOpenGroupDisplayName(sender); + if (threadRecipient.isGroupRecipient()) { + displayName = getGroupDisplayName(sender, threadRecipient.isCommunityRecipient()); } if (privacy.isDisplayMessage()) { SpannableStringBuilder builder = new SpannableStringBuilder(); @@ -113,14 +113,15 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu } /** - * @param recipient the * individual * recipient for which to get the open group display name. + * @param recipient the * individual * recipient for which to get the display name. + * @param openGroupRecipient whether in an open group context */ - private String getOpenGroupDisplayName(Recipient recipient) { + private String getGroupDisplayName(Recipient recipient, boolean openGroupRecipient) { SessionContactDatabase contactDB = DatabaseComponent.get(context).sessionContactDatabase(); String sessionID = recipient.getAddress().serialize(); Contact contact = contactDB.getContactWithSessionID(sessionID); if (contact == null) { return sessionID; } - String displayName = contact.displayName(Contact.ContactContext.OPEN_GROUP); + String displayName = contact.displayName(openGroupRecipient ? Contact.ContactContext.OPEN_GROUP : Contact.ContactContext.REGULAR); if (displayName == null) { return sessionID; } return displayName; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java index 991989e8da..0d57751171 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java @@ -4,14 +4,15 @@ import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.net.Uri; +import android.os.Build; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.TaskStackBuilder; - +import org.session.libsession.utilities.recipients.Recipient; import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; import org.thoughtcrime.securesms.mms.SlideDeck; -import org.session.libsession.utilities.recipients.Recipient; public class NotificationItem { @@ -75,9 +76,14 @@ public class NotificationItem { intent.putExtra(ConversationActivityV2.THREAD_ID, threadId); intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); + int intentFlags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + intentFlags |= PendingIntent.FLAG_MUTABLE; + } + return TaskStackBuilder.create(context) .addNextIntentWithParentStack(intent) - .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); + .getPendingIntent(0, intentFlags); } public long getId() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java index fe934e229f..108aa12c51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java @@ -4,12 +4,14 @@ import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.net.Uri; +import android.os.Build; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.session.libsignal.utilities.Log; import org.session.libsession.utilities.recipients.Recipient; -import org.session.libsession.utilities.recipients.Recipient.*; +import org.session.libsession.utilities.recipients.Recipient.VibrateState; +import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2; import java.util.LinkedHashSet; @@ -114,7 +116,12 @@ public class NotificationState { intent.putExtra(MarkReadReceiver.THREAD_IDS_EXTRA, threadArray); intent.putExtra(MarkReadReceiver.NOTIFICATION_ID_EXTRA, notificationId); - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + int intentFlags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + intentFlags |= PendingIntent.FLAG_MUTABLE; + } + + return PendingIntent.getBroadcast(context, 0, intent, intentFlags); } public PendingIntent getRemoteReplyIntent(Context context, Recipient recipient, ReplyMethod replyMethod) { @@ -127,7 +134,12 @@ public class NotificationState { intent.putExtra(RemoteReplyReceiver.REPLY_METHOD, replyMethod); intent.setPackage(context.getPackageName()); - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + int intentFlags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + intentFlags |= PendingIntent.FLAG_MUTABLE; + } + + return PendingIntent.getBroadcast(context, 0, intent, intentFlags); } public PendingIntent getAndroidAutoReplyIntent(Context context, Recipient recipient) { @@ -141,7 +153,12 @@ public class NotificationState { intent.putExtra(AndroidAutoReplyReceiver.THREAD_ID_EXTRA, (long)threads.toArray()[0]); intent.setPackage(context.getPackageName()); - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + int intentFlags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + intentFlags |= PendingIntent.FLAG_MUTABLE; + } + + return PendingIntent.getBroadcast(context, 0, intent, intentFlags); } public PendingIntent getAndroidAutoHeardIntent(Context context, int notificationId) { @@ -160,7 +177,12 @@ public class NotificationState { intent.putExtra(AndroidAutoHeardReceiver.NOTIFICATION_ID_EXTRA, notificationId); intent.setPackage(context.getPackageName()); - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + int intentFlags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + intentFlags |= PendingIntent.FLAG_MUTABLE; + } + + return PendingIntent.getBroadcast(context, 0, intent, intentFlags); } public PendingIntent getQuickReplyIntent(Context context, Recipient recipient) { @@ -171,7 +193,12 @@ public class NotificationState { intent.putExtra(ConversationActivityV2.THREAD_ID, (long)threads.toArray()[0]); intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); - return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + int intentFlags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + intentFlags |= PendingIntent.FLAG_MUTABLE; + } + + return PendingIntent.getActivity(context, 0, intent, intentFlags); } public PendingIntent getDeleteIntent(Context context) { @@ -190,7 +217,12 @@ public class NotificationState { intent.putExtra(DeleteNotificationReceiver.EXTRA_MMS, mms); intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + int intentFlags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + intentFlags |= PendingIntent.FLAG_MUTABLE; + } + + return PendingIntent.getBroadcast(context, 0, intent, intentFlags); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java index 1d19c2c8e3..935d575c56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java @@ -4,12 +4,13 @@ package org.thoughtcrime.securesms.notifications; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; + import androidx.core.app.NotificationCompat; -import org.session.libsession.utilities.recipients.Recipient; -import org.thoughtcrime.securesms.home.HomeActivity; import org.session.libsession.utilities.NotificationPrivacyPreference; import org.session.libsession.utilities.TextSecurePreferences; +import org.session.libsession.utilities.recipients.Recipient; +import org.thoughtcrime.securesms.home.HomeActivity; import network.loki.messenger.R; @@ -28,7 +29,7 @@ public class PendingMessageNotificationBuilder extends AbstractNotificationBuild setContentText(context.getString(R.string.MessageNotifier_you_have_pending_signal_messages)); setTicker(context.getString(R.string.MessageNotifier_you_have_pending_signal_messages)); - setContentIntent(PendingIntent.getActivity(context, 0, intent, 0)); + setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)); setAutoCancel(true); setAlarms(null, Recipient.VibrateState.DEFAULT); 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 7925b8556a..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 { @@ -117,15 +117,15 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil setNumber(messageCount); } - public void setPrimaryMessageBody(@NonNull Recipient threadRecipients, + public void setPrimaryMessageBody(@NonNull Recipient threadRecipient, @NonNull Recipient individualRecipient, @NonNull CharSequence message, @Nullable SlideDeck slideDeck) { SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); - if (privacy.isDisplayContact() && threadRecipients.isOpenGroupRecipient()) { - String displayName = getOpenGroupDisplayName(individualRecipient); + if (privacy.isDisplayContact() && threadRecipient.isGroupRecipient()) { + String displayName = getGroupDisplayName(individualRecipient, threadRecipient.isCommunityRecipient()); stringBuilder.append(Util.getBoldedString(displayName + ": ")); } @@ -214,8 +214,8 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil { SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); - if (privacy.isDisplayContact() && threadRecipient.isOpenGroupRecipient()) { - String displayName = getOpenGroupDisplayName(individualRecipient); + if (privacy.isDisplayContact() && threadRecipient.isGroupRecipient()) { + 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); } } @@ -334,14 +334,15 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil } /** - * @param recipient the * individual * recipient for which to get the open group display name. + * @param recipient the * individual * recipient for which to get the display name. + * @param openGroupRecipient whether in an open group context */ - private String getOpenGroupDisplayName(Recipient recipient) { + private String getGroupDisplayName(Recipient recipient, boolean openGroupRecipient) { SessionContactDatabase contactDB = DatabaseComponent.get(context).sessionContactDatabase(); String sessionID = recipient.getAddress().serialize(); Contact contact = contactDB.getContactWithSessionID(sessionID); if (contact == null) { return sessionID; } - String displayName = contact.displayName(Contact.ContactContext.OPEN_GROUP); + String displayName = contact.displayName(openGroupRecipient ? Contact.ContactContext.OPEN_GROUP : Contact.ContactContext.REGULAR); if (displayName == null) { return sessionID; } return displayName; } 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 ee1631a00d..1c10571dbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt @@ -15,6 +15,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentPagerAdapter import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter @@ -22,8 +23,10 @@ import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ActivityLinkDeviceBinding import network.loki.messenger.databinding.FragmentRecoveryPhraseBinding +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.Log @@ -32,16 +35,26 @@ import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.crypto.MnemonicUtilities +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo +import javax.inject.Inject +@AndroidEntryPoint class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate { + + @Inject + lateinit var configFactory: ConfigFactory + private lateinit var binding: ActivityLinkDeviceBinding + internal val database: LokiAPIDatabaseProtocol + get() = SnodeModule.shared.storage 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() @@ -99,10 +112,16 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel if (restoreJob?.isActive == true) return restoreJob = lifecycleScope.launch { + // 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() + // RestoreActivity handles seed this way val keyPairGenerationResult = KeyPairUtilities.generate(seed) val x25519KeyPair = keyPairGenerationResult.x25519KeyPair KeyPairUtilities.store(this@LinkDeviceActivity, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair) + configFactory.keyPairChanged() val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey val registrationID = KeyHelper.generateRegistrationId(false) TextSecurePreferences.setLocalRegistrationId(this@LinkDeviceActivity, registrationID) @@ -115,9 +134,8 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel .setAction(R.string.registration_activity__skip) { register(true) } val skipJob = launch { - delay(30_000L) + delay(15_000L) snackBar.show() - // show a dialog or something saying do you want to skip this bit? } // start polling and wait for updated message ApplicationContext.getInstance(this@LinkDeviceActivity).apply { diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt index 92583d89e5..e4e8e6a9a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/PNModeActivity.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.onboarding import android.animation.ArgbEvaluator import android.animation.ValueAnimator -import android.app.AlertDialog import android.content.Intent import android.graphics.drawable.TransitionDrawable import android.net.Uri @@ -13,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 @@ -20,6 +20,9 @@ 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 import org.thoughtcrime.securesms.util.disableClipping @@ -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 @@ -52,7 +60,7 @@ class PNModeActivity : BaseActionBarActivity() { toggleFCM() } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { + override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_pn_mode, menu) return true } @@ -151,18 +159,20 @@ class PNModeActivity : BaseActionBarActivity() { private fun register() { if (selectedOptionView == null) { - val dialog = AlertDialog.Builder(this) - dialog.setTitle(R.string.activity_pn_mode_no_option_picked_dialog_title) - dialog.setPositiveButton(R.string.ok) { _, _ -> } - dialog.create().show() + showSessionDialog { + title(R.string.activity_pn_mode_no_option_picked_dialog_title) + button(R.string.ok) + } return } - TextSecurePreferences.setIsUsingFCM(this, (selectedOptionView == binding.fcmOptionView)) + + 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) show(intent) } // endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt deleted file mode 100644 index 6a1c785ad6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RecoveryPhraseRestoreActivity.kt +++ /dev/null @@ -1,96 +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 network.loki.messenger.R -import network.loki.messenger.databinding.ActivityRecoveryPhraseRestoreBinding -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.crypto.MnemonicCodec -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.util.push -import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo - -class RecoveryPhraseRestoreActivity : BaseActionBarActivity() { - private lateinit var binding: ActivityRecoveryPhraseRestoreBinding - // 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 { - 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) - 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 0105fbedf0..13e5b51f0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/RegisterActivity.kt @@ -16,19 +16,33 @@ import android.text.style.StyleSpan import android.view.View import android.widget.Toast import com.goterl.lazysodium.utils.KeyPair +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ActivityRegisterBinding +import org.session.libsession.snode.SnodeModule import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.crypto.ecc.ECKeyPair +import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo +import javax.inject.Inject +@AndroidEntryPoint class RegisterActivity : BaseActionBarActivity() { + + private val temporarySeedKey = "TEMPORARY_SEED_KEY" + + @Inject + lateinit var configFactory: ConfigFactory + private lateinit var binding: ActivityRegisterBinding + internal val database: LokiAPIDatabaseProtocol + get() = SnodeModule.shared.storage private var seed: ByteArray? = null private var ed25519KeyPair: KeyPair? = null private var x25519KeyPair: ECKeyPair? = null @@ -65,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() { @@ -109,7 +130,12 @@ class RegisterActivity : BaseActionBarActivity() { // region Interaction private fun register() { + // 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() KeyPairUtilities.store(this, seed!!, ed25519KeyPair!!, x25519KeyPair!!) + configFactory.keyPairChanged() val userHexEncodedPublicKey = x25519KeyPair!!.hexEncodedPublicKey val registrationID = KeyHelper.generateRegistrationId(false) TextSecurePreferences.setLocalRegistrationId(this, registrationID) diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java index 999dad001d..88ee67cb4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.permissions; import android.app.Activity; import android.app.AlertDialog; import android.content.Context; +import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; @@ -12,6 +13,7 @@ import android.util.DisplayMetrics; import android.view.Display; import android.view.ViewGroup; import android.view.WindowManager; +import android.widget.Button; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; @@ -160,14 +162,13 @@ public class Permissions { request.onResult(requestedPermissions, grantResults, new boolean[requestedPermissions.length]); } - @SuppressWarnings("ConstantConditions") private void executePermissionsRequestWithRationale(PermissionsRequest request) { - RationaleDialog.createFor(permissionObject.getContext(), rationaleDialogMessage, rationalDialogHeader) - .setPositiveButton(R.string.Permissions_continue, (dialog, which) -> executePermissionsRequest(request)) - .setNegativeButton(R.string.Permissions_not_now, (dialog, which) -> executeNoPermissionsRequest(request)) - .show() - .getWindow() - .setLayout((int)(permissionObject.getWindowWidth() * .75), ViewGroup.LayoutParams.WRAP_CONTENT); + RationaleDialog.show( + permissionObject.getContext(), + rationaleDialogMessage, + () -> executePermissionsRequest(request), + () -> executeNoPermissionsRequest(request), + rationalDialogHeader); } private void executePermissionsRequest(PermissionsRequest request) { @@ -254,7 +255,7 @@ public class Permissions { resultListener.onResult(permissions, grantResults, shouldShowRationaleDialog); } - private static Intent getApplicationSettingsIntent(@NonNull Context context) { + static Intent getApplicationSettingsIntent(@NonNull Context context) { Intent intent = new Intent(); intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); Uri uri = Uri.fromParts("package", context.getPackageName(), null); @@ -351,15 +352,8 @@ public class Permissions { @Override public void run() { Context context = this.context.get(); - - if (context != null) { - new AlertDialog.Builder(context, R.style.ThemeOverlay_Session_AlertDialog) - .setTitle(R.string.Permissions_permission_required) - .setMessage(message) - .setPositiveButton(R.string.Permissions_continue, (dialog, which) -> context.startActivity(getApplicationSettingsIntent(context))) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } + if (context == null) return; + SettingsDialog.show(context, message); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.java b/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.java deleted file mode 100644 index a346d591ac..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.thoughtcrime.securesms.permissions; - - -import android.app.AlertDialog; -import android.content.Context; -import android.graphics.Color; -import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.LinearLayout.LayoutParams; -import android.widget.TextView; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; - -import org.session.libsession.utilities.ViewUtil; - -import network.loki.messenger.R; - -public class RationaleDialog { - - public static AlertDialog.Builder createFor(@NonNull Context context, @NonNull String message, @DrawableRes int... drawables) { - View view = LayoutInflater.from(context).inflate(R.layout.permissions_rationale_dialog, null); - view.setClipToOutline(true); - ViewGroup header = view.findViewById(R.id.header_container); - TextView text = view.findViewById(R.id.message); - - for (int i=0;i<drawables.length;i++) { - ImageView imageView = new ImageView(context); - imageView.setImageDrawable(context.getResources().getDrawable(drawables[i])); - imageView.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); - - header.addView(imageView); - - if (i != drawables.length - 1) { - TextView plus = new TextView(context); - plus.setText("+"); - plus.setTextSize(TypedValue.COMPLEX_UNIT_SP, 40); - plus.setTextColor(Color.WHITE); - - LayoutParams layoutParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); - layoutParams.setMargins(ViewUtil.dpToPx(context, 20), 0, ViewUtil.dpToPx(context, 20), 0); - - plus.setLayoutParams(layoutParams); - header.addView(plus); - } - } - - text.setText(message); - - return new AlertDialog.Builder(context, R.style.ThemeOverlay_Session_AlertDialog).setView(view); - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.kt new file mode 100644 index 0000000000..373da62c12 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/RationaleDialog.kt @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.permissions + +import android.content.Context +import android.graphics.Color +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams.WRAP_CONTENT +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.appcompat.app.AlertDialog +import androidx.core.content.res.ResourcesCompat +import network.loki.messenger.R +import org.session.libsession.utilities.ViewUtil +import org.thoughtcrime.securesms.showSessionDialog + +object RationaleDialog { + @JvmStatic + fun show( + context: Context, + message: String, + onPositive: Runnable, + onNegative: Runnable, + @DrawableRes vararg drawables: Int + ): AlertDialog { + val view = LayoutInflater.from(context).inflate(R.layout.permissions_rationale_dialog, null) + .apply { clipToOutline = true } + val header = view.findViewById<ViewGroup>(R.id.header_container) + view.findViewById<TextView>(R.id.message).text = message + + fun addIcon(id: Int) { + ImageView(context).apply { + setImageDrawable(ResourcesCompat.getDrawable(context.resources, id, context.theme)) + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) + }.also(header::addView) + } + + fun addPlus() { + TextView(context).apply { + text = "+" + setTextSize(TypedValue.COMPLEX_UNIT_SP, 40f) + setTextColor(Color.WHITE) + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + ViewUtil.dpToPx(context, 20).let { setMargins(it, 0, it, 0) } + } + }.also(header::addView) + } + + drawables.firstOrNull()?.let(::addIcon) + drawables.drop(1).forEach { addPlus(); addIcon(it) } + + return context.showSessionDialog { + view(view) + button(R.string.Permissions_continue) { onPositive.run() } + button(R.string.Permissions_not_now) { onNegative.run() } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/SettingsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/permissions/SettingsDialog.kt new file mode 100644 index 0000000000..a4efd8d870 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/SettingsDialog.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.permissions + +import android.content.Context +import network.loki.messenger.R +import org.thoughtcrime.securesms.showSessionDialog + +class SettingsDialog { + companion object { + @JvmStatic + fun show(context: Context, message: String) { + context.showSessionDialog { + title(R.string.Permissions_permission_required) + text(message) + button(R.string.Permissions_continue, R.string.AccessibilityId_continue) { + context.startActivity(Permissions.getApplicationSettingsIntent(context)) + } + cancelButton() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt index 504194d3a4..16499cc4bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt @@ -1,67 +1,29 @@ package org.thoughtcrime.securesms.preferences -import android.app.AlertDialog import android.os.Bundle -import android.view.View import androidx.activity.viewModels import androidx.core.view.isVisible import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ActivityBlockedContactsBinding import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity +import org.thoughtcrime.securesms.showSessionDialog @AndroidEntryPoint -class BlockedContactsActivity: PassphraseRequiredActionBarActivity(), View.OnClickListener { +class BlockedContactsActivity: PassphraseRequiredActionBarActivity() { lateinit var binding: ActivityBlockedContactsBinding val viewModel: BlockedContactsViewModel by viewModels() - val adapter = BlockedContactsAdapter() + val adapter: BlockedContactsAdapter by lazy { BlockedContactsAdapter(viewModel) } - override fun onClick(v: View?) { - if (v === binding.unblockButton && adapter.getSelectedItems().isNotEmpty()) { - val contactsToUnblock = adapter.getSelectedItems() - // show dialog - val title = if (contactsToUnblock.size == 1) { - getString(R.string.Unblock_dialog__title_single, contactsToUnblock.first().name) - } else { - getString(R.string.Unblock_dialog__title_multiple) - } - - val message = if (contactsToUnblock.size == 1) { - getString(R.string.Unblock_dialog__message, contactsToUnblock.first().name) - } else { - val stringBuilder = StringBuilder() - val iterator = contactsToUnblock.iterator() - var numberAdded = 0 - while (iterator.hasNext() && numberAdded < 3) { - val nextRecipient = iterator.next() - if (numberAdded > 0) stringBuilder.append(", ") - - stringBuilder.append(nextRecipient.name) - numberAdded++ - } - val overflow = contactsToUnblock.size - numberAdded - if (overflow > 0) { - stringBuilder.append(" ") - val string = resources.getQuantityString(R.plurals.Unblock_dialog__message_multiple_overflow, overflow) - stringBuilder.append(string.format(overflow)) - } - getString(R.string.Unblock_dialog__message, stringBuilder.toString()) - } - - AlertDialog.Builder(this) - .setTitle(title) - .setMessage(message) - .setPositiveButton(R.string.continue_2) { d, _ -> - viewModel.unblock(contactsToUnblock) - d.dismiss() - } - .setNegativeButton(R.string.cancel) { d, _ -> - d.dismiss() - } - .show() + fun unblock() { + showSessionDialog { + title(viewModel.getTitle(this@BlockedContactsActivity)) + text(viewModel.getMessage(this@BlockedContactsActivity)) + button(R.string.continue_2) { viewModel.unblock() } + cancelButton() } } @@ -73,15 +35,14 @@ class BlockedContactsActivity: PassphraseRequiredActionBarActivity(), View.OnCli binding.recyclerView.adapter = adapter viewModel.subscribe(this) - .observe(this) { newState -> - adapter.submitList(newState.blockedContacts) - val isEmpty = newState.blockedContacts.isEmpty() - binding.emptyStateMessageTextView.isVisible = isEmpty - binding.nonEmptyStateGroup.isVisible = !isEmpty + .observe(this) { state -> + adapter.submitList(state.items) + binding.emptyStateMessageTextView.isVisible = state.emptyStateMessageTextViewVisible + binding.nonEmptyStateGroup.isVisible = state.nonEmptyStateGroupVisible + binding.unblockButton.isEnabled = state.unblockButtonEnabled } - binding.unblockButton.setOnClickListener(this) + binding.unblockButton.setOnClickListener { unblock() } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt index 50af49b557..e0b92bdbea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsAdapter.kt @@ -10,43 +10,35 @@ import network.loki.messenger.R import network.loki.messenger.databinding.BlockedContactLayoutBinding import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.util.adapter.SelectableItem -class BlockedContactsAdapter: ListAdapter<Recipient,BlockedContactsAdapter.ViewHolder>(RecipientDiffer()) { +typealias SelectableRecipient = SelectableItem<Recipient> - class RecipientDiffer: DiffUtil.ItemCallback<Recipient>() { - override fun areItemsTheSame(oldItem: Recipient, newItem: Recipient) = oldItem === newItem - override fun areContentsTheSame(oldItem: Recipient, newItem: Recipient) = oldItem == newItem +class BlockedContactsAdapter(val viewModel: BlockedContactsViewModel) : ListAdapter<SelectableRecipient,BlockedContactsAdapter.ViewHolder>(RecipientDiffer()) { + + class RecipientDiffer: DiffUtil.ItemCallback<SelectableRecipient>() { + override fun areItemsTheSame(old: SelectableRecipient, new: SelectableRecipient) = old.item.address == new.item.address + override fun areContentsTheSame(old: SelectableRecipient, new: SelectableRecipient) = old.isSelected == new.isSelected + override fun getChangePayload(old: SelectableRecipient, new: SelectableRecipient) = new.isSelected } - private val selectedItems = mutableListOf<Recipient>() - - fun getSelectedItems() = selectedItems - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val itemView = LayoutInflater.from(parent.context).inflate(R.layout.blocked_contact_layout, parent, false) - return ViewHolder(itemView) - } - - private fun toggleSelection(recipient: Recipient, isSelected: Boolean, position: Int) { - if (isSelected) { - selectedItems -= recipient - } else { - selectedItems += recipient - } - notifyItemChanged(position) - } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = + LayoutInflater.from(parent.context) + .inflate(R.layout.blocked_contact_layout, parent, false) + .let(::ViewHolder) override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val recipient = getItem(position) - val isSelected = recipient in selectedItems - holder.bind(recipient, isSelected) { - toggleSelection(recipient, isSelected, position) - } + holder.bind(getItem(position), viewModel::toggle) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) { + if (payloads.isEmpty()) holder.bind(getItem(position), viewModel::toggle) + else holder.select(getItem(position).isSelected) } override fun onViewRecycled(holder: ViewHolder) { super.onViewRecycled(holder) - holder.binding.profilePictureView.root.recycle() + holder.binding.profilePictureView.recycle() } class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { @@ -54,15 +46,17 @@ class BlockedContactsAdapter: ListAdapter<Recipient,BlockedContactsAdapter.ViewH val glide = GlideApp.with(itemView) val binding = BlockedContactLayoutBinding.bind(itemView) - fun bind(recipient: Recipient, isSelected: Boolean, toggleSelection: () -> Unit) { - binding.recipientName.text = recipient.name - with (binding.profilePictureView.root) { - glide = this@ViewHolder.glide - update(recipient) + fun bind(selectable: SelectableRecipient, toggle: (SelectableRecipient) -> Unit) { + binding.recipientName.text = selectable.item.name + with (binding.profilePictureView) { + update(selectable.item) } - binding.root.setOnClickListener { toggleSelection() } + binding.root.setOnClickListener { toggle(selectable) } + binding.selectButton.isSelected = selectable.isSelected + } + + fun select(isSelected: Boolean) { binding.selectButton.isSelected = isSelected } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsLayout.kt deleted file mode 100644 index ed2970fbc4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsLayout.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.thoughtcrime.securesms.preferences - -import android.content.Context -import android.util.AttributeSet -import android.widget.FrameLayout - -class BlockedContactsLayout @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : FrameLayout(context, attrs) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsPreference.kt index 254d34978d..48c7cc6dc8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsPreference.kt @@ -3,26 +3,19 @@ package org.thoughtcrime.securesms.preferences import android.content.Context import android.content.Intent import android.util.AttributeSet -import android.view.View import androidx.preference.PreferenceCategory import androidx.preference.PreferenceViewHolder class BlockedContactsPreference @JvmOverloads constructor( context: Context, - attributeSet: AttributeSet? = null) : PreferenceCategory(context, attributeSet), View.OnClickListener { - - override fun onClick(v: View?) { - if (v is BlockedContactsLayout) { - val intent = Intent(context, BlockedContactsActivity::class.java) - context.startActivity(intent) - } - } + attributeSet: AttributeSet? = null +) : PreferenceCategory(context, attributeSet) { override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) - val itemView = holder.itemView - itemView.setOnClickListener(this) + holder.itemView.setOnClickListener { + Intent(context, BlockedContactsActivity::class.java).let(context::startActivity) + } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt index 9c0a436ebb..dbe09668c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt @@ -7,8 +7,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.cash.copper.flow.observeQuery import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.collect @@ -17,9 +19,12 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.withContext +import network.loki.messenger.R import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import org.thoughtcrime.securesms.util.adapter.SelectableItem import javax.inject.Inject @HiltViewModel @@ -29,7 +34,9 @@ class BlockedContactsViewModel @Inject constructor(private val storage: Storage) private val listUpdateChannel = Channel<Unit>(capacity = Channel.CONFLATED) - private val _contacts = MutableLiveData(BlockedContactsViewState(emptyList())) + private val _state = MutableLiveData(BlockedContactsViewState()) + + val state get() = _state.value!! fun subscribe(context: Context): LiveData<BlockedContactsViewState> { executor.launch(IO) { @@ -45,21 +52,74 @@ class BlockedContactsViewModel @Inject constructor(private val storage: Storage) } executor.launch(IO) { for (update in listUpdateChannel) { - val blockedContactState = BlockedContactsViewState(storage.blockedContacts().sortedBy { it.name }) + val blockedContactState = state.copy( + blockedContacts = storage.blockedContacts().sortedBy { it.name } + ) withContext(Main) { - _contacts.value = blockedContactState + _state.value = blockedContactState } } } - return _contacts + return _state } - fun unblock(toUnblock: List<Recipient>) { - storage.unblock(toUnblock) + fun unblock() { + storage.setBlocked(state.selectedItems, false) + _state.value = state.copy(selectedItems = emptySet()) + } + + fun select(selectedItem: Recipient, isSelected: Boolean) { + _state.value = state.run { + if (isSelected) copy(selectedItems = selectedItems + selectedItem) + else copy(selectedItems = selectedItems - selectedItem) + } + } + + fun getTitle(context: Context): String = + if (state.selectedItems.size == 1) { + context.getString(R.string.Unblock_dialog__title_single, state.selectedItems.first().name) + } else { + context.getString(R.string.Unblock_dialog__title_multiple) + } + + fun getMessage(context: Context): String { + if (state.selectedItems.size == 1) { + return context.getString(R.string.Unblock_dialog__message, state.selectedItems.first().name) + } + val stringBuilder = StringBuilder() + val iterator = state.selectedItems.iterator() + var numberAdded = 0 + while (iterator.hasNext() && numberAdded < 3) { + val nextRecipient = iterator.next() + if (numberAdded > 0) stringBuilder.append(", ") + + stringBuilder.append(nextRecipient.name) + numberAdded++ + } + val overflow = state.selectedItems.size - numberAdded + if (overflow > 0) { + stringBuilder.append(" ") + val string = context.resources.getQuantityString(R.plurals.Unblock_dialog__message_multiple_overflow, overflow) + stringBuilder.append(string.format(overflow)) + } + return context.getString(R.string.Unblock_dialog__message, stringBuilder.toString()) + } + + fun toggle(selectable: SelectableItem<Recipient>) { + _state.value = state.run { + if (selectable.item in selectedItems) copy(selectedItems = selectedItems - selectable.item) + else copy(selectedItems = selectedItems + selectable.item) + } } data class BlockedContactsViewState( - val blockedContacts: List<Recipient> - ) + val blockedContacts: List<Recipient> = emptyList(), + val selectedItems: Set<Recipient> = emptySet() + ) { + val items = blockedContacts.map { SelectableItem(it, it in selectedItems) } -} \ No newline at end of file + val unblockButtonEnabled get() = selectedItems.isNotEmpty() + val emptyStateMessageTextViewVisible get() = blockedContacts.isEmpty() + val nonEmptyStateGroupVisible get() = blockedContacts.isNotEmpty() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/CallToggleListener.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/CallToggleListener.kt new file mode 100644 index 0000000000..ea747798c8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CallToggleListener.kt @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.preferences + +import android.Manifest +import androidx.fragment.app.Fragment +import androidx.preference.Preference +import network.loki.messenger.R +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.TextSecurePreferences.Companion.setBooleanPreference +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.showSessionDialog + +internal class CallToggleListener( + private val context: Fragment, + private val setCallback: (Boolean) -> Unit +) : Preference.OnPreferenceChangeListener { + + override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { + if (newValue == false) return true + + // check if we've shown the info dialog and check for microphone permissions + context.showSessionDialog { + title(R.string.dialog_voice_video_title) + text(R.string.dialog_voice_video_message) + button(R.string.dialog_link_preview_enable_button_title, R.string.AccessibilityId_enable) { requestMicrophonePermission() } + cancelButton() + } + + return false + } + + private fun requestMicrophonePermission() { + Permissions.with(context) + .request(Manifest.permission.RECORD_AUDIO) + .onAllGranted { + setBooleanPreference( + context.requireContext(), + TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED, + true + ) + setCallback(true) + } + .onAnyDenied { setCallback(false) } + .execute() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChangeUiModeDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ChangeUiModeDialog.kt deleted file mode 100644 index 3d5b9e2e99..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ChangeUiModeDialog.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.thoughtcrime.securesms.preferences - -import android.app.Dialog -import android.os.Bundle -import androidx.fragment.app.DialogFragment - -class ChangeUiModeDialog : DialogFragment() { - - companion object { - const val TAG = "ChangeUiModeDialog" - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val context = requireContext() - return android.app.AlertDialog.Builder(context) - .setTitle("TODO: remove this") - .show() - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt index 560c137104..31f2782f8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ClearAllDataDialog.kt @@ -1,9 +1,12 @@ package org.thoughtcrime.securesms.preferences +import android.app.Dialog +import android.os.Bundle import android.view.LayoutInflater -import androidx.appcompat.app.AlertDialog +import android.view.View import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import kotlinx.coroutines.Dispatchers @@ -12,13 +15,15 @@ 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.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.createSessionDialog +import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities -class ClearAllDataDialog : BaseDialog() { +class ClearAllDataDialog : DialogFragment() { private lateinit var binding: DialogClearAllDataBinding enum class Steps { @@ -35,13 +40,18 @@ class ClearAllDataDialog : BaseDialog() { updateUI() } - override fun setContentView(builder: AlertDialog.Builder) { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { + view(createView()) + } + + private fun createView(): View { binding = DialogClearAllDataBinding.inflate(LayoutInflater.from(requireContext())) - val device = RadioOption("deviceOnly", requireContext().getString(R.string.dialog_clear_all_data_clear_device_only)) - val network = RadioOption("deviceAndNetwork", requireContext().getString(R.string.dialog_clear_all_data_clear_device_and_network)) - 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 adapter = optionAdapter addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) setHasFixedSize(true) @@ -61,8 +71,7 @@ class ClearAllDataDialog : BaseDialog() { Steps.DELETING -> { /* do nothing intentionally */ } } } - builder.setView(binding.root) - builder.setCancelable(false) + return binding.root } private fun updateUI() { @@ -108,6 +117,10 @@ class ClearAllDataDialog : BaseDialog() { } 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/CorrectedPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java index badcbe66b8..8c3e6190ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java @@ -24,8 +24,6 @@ import androidx.recyclerview.widget.RecyclerView; import org.thoughtcrime.securesms.components.CustomDefaultPreference; import org.thoughtcrime.securesms.conversation.v2.ViewUtil; -import org.thoughtcrime.securesms.preferences.widgets.ColorPickerPreference; -import org.thoughtcrime.securesms.preferences.widgets.ColorPickerPreferenceDialogFragmentCompat; import network.loki.messenger.R; @@ -60,9 +58,7 @@ public abstract class CorrectedPreferenceFragment extends PreferenceFragmentComp public void onDisplayPreferenceDialog(Preference preference) { DialogFragment dialogFragment = null; - if (preference instanceof ColorPickerPreference) { - dialogFragment = ColorPickerPreferenceDialogFragmentCompat.newInstance(preference.getKey()); - } else if (preference instanceof CustomDefaultPreference) { + if (preference instanceof CustomDefaultPreference) { dialogFragment = CustomDefaultPreference.CustomDefaultPreferenceDialogFragmentCompat.newInstance(preference.getKey()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/HelpSettingsActivity.kt index 90ffbd4b13..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 @@ -41,8 +46,7 @@ class HelpSettingsFragment: CorrectedPreferenceFragment() { addPreferencesFromResource(R.xml.preferences_help) } - override fun onPreferenceTreeClick(preference: Preference?): Boolean { - preference ?: return false + override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { EXPORT_LOGS -> { shareLogs() @@ -68,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) @@ -77,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/ListPreferenceDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ListPreferenceDialog.kt index e407c67774..6f0998eecb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ListPreferenceDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ListPreferenceDialog.kt @@ -1,42 +1,24 @@ package org.thoughtcrime.securesms.preferences -import android.view.LayoutInflater +import android.content.Context import androidx.appcompat.app.AlertDialog import androidx.preference.ListPreference -import androidx.recyclerview.widget.DividerItemDecoration -import network.loki.messenger.databinding.DialogListPreferenceBinding -import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.showSessionDialog -class ListPreferenceDialog( - private val listPreference: ListPreference, - private val dialogListener: () -> Unit -) : BaseDialog() { - private lateinit var binding: DialogListPreferenceBinding +fun listPreferenceDialog( + context: Context, + listPreference: ListPreference, + onChange: () -> Unit +) : AlertDialog = listPreference.run { + context.showSessionDialog { + val index = entryValues.indexOf(value) + val options = entries.map(CharSequence::toString).toTypedArray() - override fun setContentView(builder: AlertDialog.Builder) { - binding = DialogListPreferenceBinding.inflate(LayoutInflater.from(requireContext())) - binding.titleTextView.text = listPreference.dialogTitle - binding.messageTextView.text = listPreference.dialogMessage - binding.closeButton.setOnClickListener { - dismiss() + title(dialogTitle) + text(dialogMessage) + singleChoiceItems(options, index) { + listPreference.setValueIndex(it) + onChange() } - val options = listPreference.entryValues.zip(listPreference.entries) { value, title -> - RadioOption(value.toString(), title.toString()) - } - val valueIndex = listPreference.findIndexOfValue(listPreference.value) - val optionAdapter = RadioOptionAdapter(valueIndex) { - listPreference.value = it.value - dismiss() - dialogListener.invoke() - } - binding.recyclerView.apply { - adapter = optionAdapter - addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) - setHasFixedSize(true) - } - optionAdapter.submitList(options) - builder.setView(binding.root) - builder.setCancelable(false) } - -} \ No newline at end of file +} 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 9ae78fc5cf..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java +++ /dev/null @@ -1,180 +0,0 @@ -package org.thoughtcrime.securesms.preferences; - -import static android.app.Activity.RESULT_OK; - -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); - new ListPreferenceDialog(listPreference, () -> { - initializeListSummary((ListPreference) findPreference(TextSecurePreferences.NOTIFICATION_PRIVACY_PREF)); - return null; - }).show(getChildFragmentManager(), "ListPreferenceDialog"); - 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.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.java deleted file mode 100644 index b5774447ee..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.java +++ /dev/null @@ -1,199 +0,0 @@ -package org.thoughtcrime.securesms.preferences; - -import android.Manifest; -import android.app.Activity; -import android.app.AlertDialog; -import android.app.KeyguardManager; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.provider.Settings; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.view.ContextThemeWrapper; -import androidx.fragment.app.Fragment; -import androidx.preference.Preference; - -import org.session.libsession.utilities.TextSecurePreferences; -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; -import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.service.KeyCachingService; -import org.thoughtcrime.securesms.util.CallNotificationBuilder; -import org.thoughtcrime.securesms.util.IntentUtils; - -import kotlin.jvm.functions.Function1; -import network.loki.messenger.BuildConfig; -import network.loki.messenger.R; - -public class PrivacySettingsPreferenceFragment extends ListSummaryPreferenceFragment { - - @Override - public void onAttach(Activity activity) { - super.onAttach(activity); - } - - @Override - public void onCreate(Bundle paramBundle) { - super.onCreate(paramBundle); - - this.findPreference(TextSecurePreferences.SCREEN_LOCK).setOnPreferenceChangeListener(new ScreenLockListener()); - - this.findPreference(TextSecurePreferences.READ_RECEIPTS_PREF).setOnPreferenceChangeListener(new ReadReceiptToggleListener()); - this.findPreference(TextSecurePreferences.TYPING_INDICATORS).setOnPreferenceChangeListener(new TypingIndicatorsToggleListener()); - this.findPreference(TextSecurePreferences.LINK_PREVIEWS).setOnPreferenceChangeListener(new LinkPreviewToggleListener()); - this.findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED).setOnPreferenceChangeListener(new CallToggleListener(this, this::setCall)); - - initializeVisibility(); - } - - private Void setCall(boolean isEnabled) { - ((SwitchPreferenceCompat)findPreference(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED)).setChecked(isEnabled); - if (isEnabled && !CallNotificationBuilder.areNotificationsEnabled(requireActivity())) { - // show a dialog saying that calls won't work properly if you don't have notifications on at a system level - new AlertDialog.Builder(new ContextThemeWrapper(requireActivity(), R.style.ThemeOverlay_Session_AlertDialog)) - .setTitle(R.string.CallNotificationBuilder_system_notification_title) - .setMessage(R.string.CallNotificationBuilder_system_notification_message) - .setPositiveButton(R.string.activity_notification_settings_title, (d, w) -> { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { - Intent settingsIntent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID); - if (IntentUtils.isResolvable(requireContext(), settingsIntent)) { - startActivity(settingsIntent); - } - } else { - Intent settingsIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .setData(Uri.parse("package:"+BuildConfig.APPLICATION_ID)); - if (IntentUtils.isResolvable(requireContext(), settingsIntent)) { - startActivity(settingsIntent); - } - } - d.dismiss(); - }) - .setNeutralButton(R.string.dismiss, (d, w) -> { - // do nothing, user might have broken notifications - d.dismiss(); - }) - .show(); - } - return null; - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); - } - - @Override - public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { - addPreferencesFromResource(R.xml.preferences_app_protection); - } - - @Override - public void onResume() { - super.onResume(); - } - - private void initializeVisibility() { - if (TextSecurePreferences.isPasswordDisabled(getContext())) { - KeyguardManager keyguardManager = (KeyguardManager)getContext().getSystemService(Context.KEYGUARD_SERVICE); - if (!keyguardManager.isKeyguardSecure()) { - ((SwitchPreferenceCompat)findPreference(TextSecurePreferences.SCREEN_LOCK)).setChecked(false); - findPreference(TextSecurePreferences.SCREEN_LOCK).setEnabled(false); - } - } else { - findPreference(TextSecurePreferences.SCREEN_LOCK).setVisible(false); - findPreference(TextSecurePreferences.SCREEN_LOCK_TIMEOUT).setVisible(false); - } - } - - private class ScreenLockListener implements Preference.OnPreferenceChangeListener { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - boolean enabled = (Boolean)newValue; - - TextSecurePreferences.setScreenLockEnabled(getContext(), enabled); - - Intent intent = new Intent(getContext(), KeyCachingService.class); - intent.setAction(KeyCachingService.LOCK_TOGGLED_EVENT); - getContext().startService(intent); - return true; - } - } - - private class ReadReceiptToggleListener implements Preference.OnPreferenceChangeListener { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - return true; - } - } - - private class TypingIndicatorsToggleListener implements Preference.OnPreferenceChangeListener { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - boolean enabled = (boolean)newValue; - - if (!enabled) { - ApplicationContext.getInstance(requireContext()).getTypingStatusRepository().clear(); - } - - return true; - } - } - - private class LinkPreviewToggleListener implements Preference.OnPreferenceChangeListener { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - return true; - } - } - - private class CallToggleListener implements Preference.OnPreferenceChangeListener { - - private final Fragment context; - private final Function1<Boolean, Void> setCallback; - - private CallToggleListener(Fragment context, Function1<Boolean,Void> setCallback) { - this.context = context; - this.setCallback = setCallback; - } - - private void requestMicrophonePermission() { - Permissions.with(context) - .request(Manifest.permission.RECORD_AUDIO) - .onAllGranted(() -> { - TextSecurePreferences.setBooleanPreference(context.requireContext(), TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED, true); - setCallback.invoke(true); - }) - .onAnyDenied(() -> setCallback.invoke(false)) - .execute(); - } - - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - boolean val = (boolean) newValue; - if (val) { - // check if we've shown the info dialog and check for microphone permissions - new AlertDialog.Builder(new ContextThemeWrapper(context.requireContext(), R.style.ThemeOverlay_Session_AlertDialog)) - .setTitle(R.string.dialog_voice_video_title) - .setMessage(R.string.dialog_voice_video_message) - .setPositiveButton(R.string.dialog_link_preview_enable_button_title, (d, w) -> { - requestMicrophonePermission(); - }) - .setNegativeButton(R.string.cancel, (d, w) -> { - - }) - .show(); - return false; - } else { - return true; - } - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt new file mode 100644 index 0000000000..21b12496bd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/PrivacySettingsPreferenceFragment.kt @@ -0,0 +1,151 @@ +package org.thoughtcrime.securesms.preferences + +import android.app.KeyguardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import androidx.preference.Preference +import 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 +import org.session.libsession.utilities.TextSecurePreferences.Companion.isPasswordDisabled +import org.session.libsession.utilities.TextSecurePreferences.Companion.setScreenLockEnabled +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.components.SwitchPreferenceCompat +import org.thoughtcrime.securesms.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)!! + .onPreferenceChangeListener = ScreenLockListener() + findPreference<Preference>(TextSecurePreferences.TYPING_INDICATORS)!! + .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() + } + + private fun setCall(isEnabled: Boolean) { + (findPreference<Preference>(TextSecurePreferences.CALL_NOTIFICATIONS_ENABLED) as SwitchPreferenceCompat?)!!.isChecked = + isEnabled + if (isEnabled && !areNotificationsEnabled(requireActivity())) { + // show a dialog saying that calls won't work properly if you don't have notifications on at a system level + showSessionDialog { + title(R.string.CallNotificationBuilder_system_notification_title) + text(R.string.CallNotificationBuilder_system_notification_message) + button(R.string.activity_notification_settings_title) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID) + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.parse("package:" + BuildConfig.APPLICATION_ID)) + } + .apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + .takeIf { IntentUtils.isResolvable(requireContext(), it) }.let { + startActivity(it) + } + } + button(R.string.dismiss) + } + } + } + + @Deprecated("Deprecated in Java") + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array<String>, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.preferences_app_protection) + } + + override fun onResume() { + super.onResume() + } + + private fun initializeVisibility() { + if (isPasswordDisabled(requireContext())) { + val keyguardManager = + requireContext().getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + if (!keyguardManager.isKeyguardSecure) { + findPreference<SwitchPreferenceCompat>(TextSecurePreferences.SCREEN_LOCK)!!.isChecked = false + findPreference<Preference>(TextSecurePreferences.SCREEN_LOCK)!!.isEnabled = false + } + } else { + findPreference<Preference>(TextSecurePreferences.SCREEN_LOCK)!!.isVisible = false + findPreference<Preference>(TextSecurePreferences.SCREEN_LOCK_TIMEOUT)!!.isVisible = false + } + } + + private inner class ScreenLockListener : Preference.OnPreferenceChangeListener { + override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { + val enabled = newValue as Boolean + setScreenLockEnabled(context!!, enabled) + val intent = Intent(context, KeyCachingService::class.java) + intent.action = KeyCachingService.LOCK_TOGGLED_EVENT + context!!.startService(intent) + return true + } + } + + private inner class TypingIndicatorsToggleListener : Preference.OnPreferenceChangeListener { + override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { + val enabled = newValue as Boolean + if (!enabled) { + ApplicationContext.getInstance(requireContext()).typingStatusRepository.clear() + } + return true + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt index 2cb61a0e82..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 === newItem - override fun areContentsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem == newItem + 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 - notifyDataSetChanged() + 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/SeedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt index e7bfd60d3f..bae5f19605 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SeedDialog.kt @@ -1,38 +1,34 @@ package org.thoughtcrime.securesms.preferences +import android.app.Dialog import android.content.ClipData import android.content.ClipboardManager import android.content.Context -import android.view.LayoutInflater +import android.os.Bundle import android.widget.Toast -import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment import network.loki.messenger.R -import network.loki.messenger.databinding.DialogSeedBinding import org.session.libsignal.crypto.MnemonicCodec import org.session.libsignal.utilities.hexEncodedPrivateKey +import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.MnemonicUtilities -import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog - -class SeedDialog : BaseDialog() { +class SeedDialog: DialogFragment() { private val seed by lazy { - var hexEncodedSeed = IdentityKeyUtil.retrieve(requireContext(), IdentityKeyUtil.LOKI_SEED) - if (hexEncodedSeed == null) { - hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(requireContext()).hexEncodedPrivateKey // Legacy account - } - val loadFileContents: (String) -> String = { fileName -> - MnemonicUtilities.loadFileContents(requireContext(), fileName) - } - MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english) + val hexEncodedSeed = IdentityKeyUtil.retrieve(requireContext(), IdentityKeyUtil.LOKI_SEED) + ?: IdentityKeyUtil.getIdentityKeyPair(requireContext()).hexEncodedPrivateKey // Legacy account + + MnemonicCodec { fileName -> MnemonicUtilities.loadFileContents(requireContext(), fileName) } + .encode(hexEncodedSeed, MnemonicCodec.Language.Configuration.english) } - override fun setContentView(builder: AlertDialog.Builder) { - val binding = DialogSeedBinding.inflate(LayoutInflater.from(requireContext())) - binding.seedTextView.text = seed - binding.closeButton.setOnClickListener { dismiss() } - binding.copyButton.setOnClickListener { copySeed() } - builder.setView(binding.root) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { + title(R.string.dialog_seed_title) + text(R.string.dialog_seed_explanation) + text(seed, R.style.SessionIDTextView) + button(R.string.copy, R.string.AccessibilityId_copy_recovery_phrase) { copySeed() } + button(R.string.close) { dismiss() } } private fun copySeed() { @@ -42,4 +38,4 @@ class SeedDialog : BaseDialog() { Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() dismiss() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 0a56c5058d..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,24 +17,31 @@ 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 +import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.databinding.ActivitySettingsBinding +import network.loki.messenger.libsession_util.util.UserPic import nl.komponents.kovenant.Promise import nl.komponents.kovenant.all import nl.komponents.kovenant.ui.alwaysUi import nl.komponents.kovenant.ui.successUi import org.session.libsession.avatars.AvatarHelper -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.ProfileKeyUtil -import org.session.libsession.utilities.ProfilePictureUtilities +import org.session.libsession.avatars.ProfileContactPhoto +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.* import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol -import org.session.libsession.utilities.TextSecurePreferences +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 +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.home.PathActivity import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.mms.GlideApp @@ -42,6 +49,7 @@ import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints +import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.BitmapDecodingException import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities @@ -50,15 +58,18 @@ import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show import java.io.File import java.security.SecureRandom -import java.util.Date +import javax.inject.Inject +@AndroidEntryPoint class SettingsActivity : PassphraseRequiredActionBarActivity() { + + @Inject + lateinit var configFactory: ConfigFactory + private lateinit var binding: ActivitySettingsBinding private var displayNameEditActionMode: ActionMode? = null set(value) { field = value; handleDisplayNameEditActionModeChanged() } private lateinit var glide: GlideRequests - private var displayNameToBeUploaded: String? = null - private var profilePictureToBeUploaded: ByteArray? = null private var tempFile: File? = null private val hexEncodedPublicKey: String @@ -76,15 +87,11 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { super.onCreate(savedInstanceState, isReady) binding = ActivitySettingsBinding.inflate(layoutInflater) setContentView(binding.root) - val displayName = TextSecurePreferences.getProfileName(this) ?: hexEncodedPublicKey + val displayName = getDisplayName() glide = GlideApp.with(this) with(binding) { - profilePictureView.root.glide = glide - profilePictureView.root.publicKey = hexEncodedPublicKey - profilePictureView.root.displayName = displayName - profilePictureView.root.isLarge = true - profilePictureView.root.update() - profilePictureView.root.setOnClickListener { showEditProfilePictureUI() } + setupProfilePictureView(profilePictureView) + profilePictureView.setOnClickListener { showEditProfilePictureUI() } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } btnGroupNameDisplay.text = displayName publicKeyTextView.text = hexEncodedPublicKey @@ -101,7 +108,21 @@ 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)") + } + } + + private fun getDisplayName(): String = + TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(hexEncodedPublicKey) + + private fun setupProfilePictureView(view: ProfilePictureView) { + view.apply { + publicKey = hexEncodedPublicKey + displayName = getDisplayName() + isLarge = true + update() } } @@ -134,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) { @@ -154,9 +176,9 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } AsyncTask.execute { try { - profilePictureToBeUploaded = BitmapUtil.createScaledBytes(this@SettingsActivity, AvatarSelection.getResultUri(data), ProfileMediaConstraints()).bitmap + val profilePictureToBeUploaded = BitmapUtil.createScaledBytes(this@SettingsActivity, AvatarSelection.getResultUri(data), ProfileMediaConstraints()).bitmap Handler(Looper.getMainLooper()).post { - updateProfile(true) + updateProfile(true, profilePictureToBeUploaded) } } catch (e: BitmapDecodingException) { e.printStackTrace() @@ -185,45 +207,74 @@ 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) } } - private fun updateProfile(isUpdatingProfilePicture: Boolean) { + private fun updateProfile( + isUpdatingProfilePicture: Boolean, + profilePicture: ByteArray? = null, + displayName: String? = null + ) { binding.loader.isVisible = true val promises = mutableListOf<Promise<*, Exception>>() - val displayName = displayNameToBeUploaded if (displayName != null) { TextSecurePreferences.setProfileName(this, displayName) + configFactory.user?.setName(displayName) } - val profilePicture = profilePictureToBeUploaded val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(this) - if (isUpdatingProfilePicture && profilePicture != null) { - promises.add(ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this)) + if (isUpdatingProfilePicture) { + if (profilePicture != null) { + promises.add(ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this)) + } else { + MessagingModuleConfiguration.shared.storage.clearUserPic() + } } val compoundPromise = all(promises) compoundPromise.successUi { // Do this on the UI thread so that it happens before the alwaysUi clause below - if (isUpdatingProfilePicture && profilePicture != null) { + val userConfig = configFactory.user + if (isUpdatingProfilePicture) { AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture) - TextSecurePreferences.setProfileAvatarId(this, SecureRandom().nextInt()) - TextSecurePreferences.setLastProfilePictureUpload(this, Date().time) + TextSecurePreferences.setProfileAvatarId(this, profilePicture?.let { SecureRandom().nextInt() } ?: 0 ) ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey) + // new config + val url = TextSecurePreferences.getProfilePictureURL(this) + val profileKey = ProfileKeyUtil.getProfileKey(this) + if (profilePicture == null) { + userConfig?.setPic(UserPic.DEFAULT) + } else if (!url.isNullOrEmpty() && profileKey.isNotEmpty()) { + userConfig?.setPic(UserPic(url, profileKey)) + } } - if (profilePicture != null || displayName != null) { - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity) + if (userConfig != null && userConfig.needsDump()) { + configFactory.persist(userConfig, SnodeAPI.nowWithOffset) } + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity) } compoundPromise.alwaysUi { if (displayName != null) { binding.btnGroupNameDisplay.text = displayName } - if (isUpdatingProfilePicture && profilePicture != null) { - binding.profilePictureView.root.recycle() // Clear the cached image before updating - binding.profilePictureView.root.update() + if (isUpdatingProfilePicture) { + binding.profilePictureView.recycle() // Clear the cached image before updating + binding.profilePictureView.update() } - displayNameToBeUploaded = null - profilePictureToBeUploaded = null binding.loader.isVisible = false } } @@ -244,8 +295,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { Toast.makeText(this, R.string.activity_settings_display_name_too_long_error, Toast.LENGTH_SHORT).show() return false } - displayNameToBeUploaded = displayName - updateProfile(false) + updateProfile(false, displayName = displayName) return true } @@ -255,6 +305,34 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } private fun showEditProfilePictureUI() { + showSessionDialog { + title(R.string.activity_settings_set_display_picture) + view(R.layout.dialog_change_avatar) + button(R.string.activity_settings_upload) { startAvatarSelection() } + if (TextSecurePreferences.getProfileAvatarId(context) != 0) { + button(R.string.activity_settings_remove) { removeAvatar() } + } + cancelButton() + }.apply { + val profilePic = findViewById<ProfilePictureView>(R.id.profile_picture_view) + ?.also(::setupProfilePictureView) + + val pictureIcon = findViewById<View>(R.id.ic_pictures) + + val recipient = Recipient.from(context, Address.fromSerialized(hexEncodedPublicKey), false) + + val photoSet = (recipient.contactPhoto as ProfileContactPhoto).avatarObject !in setOf("0", "") + + profilePic?.isVisible = photoSet + pictureIcon?.isVisible = !photoSet + } + } + + private fun removeAvatar() { + updateProfile(true) + } + + private fun startAvatarSelection() { // Ask for an optional camera permission. Permissions.with(this) .request(Manifest.permission.CAMERA) 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 1bd8373247..9bfc1dabf2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt @@ -1,71 +1,83 @@ package org.thoughtcrime.securesms.preferences +import android.app.Dialog import android.content.ContentResolver import android.content.ContentValues import android.content.Intent import android.media.MediaScannerConnection import android.net.Uri import android.os.Build +import android.os.Bundle import android.os.Environment import android.provider.MediaStore -import android.view.LayoutInflater import android.webkit.MimeTypeMap +import android.widget.ProgressBar +import android.widget.TextView import android.widget.Toast -import androidx.appcompat.app.AlertDialog +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 network.loki.messenger.databinding.DialogShareLogsBinding + import org.session.libsignal.utilities.ExternalStorageUtil import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext -import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog +import org.thoughtcrime.securesms.createSessionDialog import org.thoughtcrime.securesms.util.FileProviderUtil import org.thoughtcrime.securesms.util.StreamUtil + import java.io.File import java.io.FileOutputStream import java.io.IOException import java.util.Objects import java.util.concurrent.TimeUnit -class ShareLogsDialog : BaseDialog() { +class ShareLogsDialog(private val updateCallback: (Boolean)->Unit): DialogFragment() { + + private val TAG = "ShareLogsDialog" private var shareJob: Job? = null - override fun setContentView(builder: AlertDialog.Builder) { - val binding = DialogShareLogsBinding.inflate(LayoutInflater.from(requireContext())) - binding.cancelButton.setOnClickListener { - dismiss() - } - binding.shareButton.setOnClickListener { - // start the export and share - shareLogs() - } - builder.setView(binding.root) - builder.setCancelable(false) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { + title(R.string.dialog_share_logs_title) + text(R.string.dialog_share_logs_explanation) + button(R.string.share, 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) @@ -79,6 +91,7 @@ class ShareLogsDialog : BaseDialog() { } } } + if (Build.VERSION.SDK_INT > 28) { updateValues.put(MediaStore.MediaColumns.IS_PENDING, 0) } @@ -101,13 +114,35 @@ class ShareLogsDialog : BaseDialog() { } 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() } } @@ -164,5 +199,4 @@ class ShareLogsDialog : BaseDialog() { return context.contentResolver.insert(outputUri, contentValues) } - } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt index bfeea554ee..823728c359 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsActivity.kt @@ -78,8 +78,6 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On viewModel.setNewAccent(R.style.PrimaryGreen) } } - } else if (v == binding.systemSettingsSwitch) { - viewModel.setNewFollowSystemSettings((v as SwitchCompat).isChecked) } } @@ -115,12 +113,8 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On super.onCreate(savedInstanceState, ready) binding = ActivityAppearanceSettingsBinding.inflate(layoutInflater) setContentView(binding.root) - savedInstanceState?.let { bundle -> - val scrollStateParcel = bundle.getSparseParcelableArray<Parcelable>(SCROLL_PARCEL) - if (scrollStateParcel != null) { - binding.scrollView.restoreHierarchyState(scrollStateParcel) - } - } + savedInstanceState?.getSparseParcelableArray<Parcelable>(SCROLL_PARCEL) + ?.let(binding.scrollView::restoreHierarchyState) supportActionBar!!.title = getString(R.string.activity_settings_message_appearance_button_title) with (binding) { // accent toggles @@ -132,7 +126,8 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On it.setOnClickListener(this@AppearanceSettingsActivity) } // system settings toggle - systemSettingsSwitch.setOnClickListener(this@AppearanceSettingsActivity) + systemSettingsSwitch.setOnCheckedChangeListener { _, isChecked -> viewModel.setNewFollowSystemSettings(isChecked) } + systemSettingsSwitchHolder.setOnClickListener { systemSettingsSwitch.toggle() } } lifecycleScope.launchWhenResumed { @@ -148,6 +143,5 @@ class AppearanceSettingsActivity: PassphraseRequiredActionBarActivity(), View.On } } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java deleted file mode 100644 index 1cccf1d524..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreference.java +++ /dev/null @@ -1,251 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Color; -import android.graphics.drawable.Drawable; -import androidx.core.content.ContextCompat; -import androidx.core.content.res.TypedArrayUtils; -import androidx.preference.DialogPreference; -import androidx.preference.PreferenceViewHolder; -import android.text.TextUtils; -import android.util.AttributeSet; -import android.widget.ImageView; - -import com.takisoft.colorpicker.ColorPickerDialog; -import com.takisoft.colorpicker.ColorPickerDialog.Size; -import com.takisoft.colorpicker.ColorStateDrawable; - -import network.loki.messenger.R; - -public class ColorPickerPreference extends DialogPreference { - - private static final String TAG = ColorPickerPreference.class.getSimpleName(); - - private int[] colors; - private CharSequence[] colorDescriptions; - private int color; - private int columns; - private int size; - private boolean sortColors; - - private ImageView colorWidget; - private OnPreferenceChangeListener listener; - - public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - - TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ColorPickerPreference, defStyleAttr, 0); - - int colorsId = a.getResourceId(R.styleable.ColorPickerPreference_colors, R.array.color_picker_default_colors); - - if (colorsId != 0) { - colors = context.getResources().getIntArray(colorsId); - } - - colorDescriptions = a.getTextArray(R.styleable.ColorPickerPreference_colorDescriptions); - color = a.getColor(R.styleable.ColorPickerPreference_currentColor, 0); - columns = a.getInt(R.styleable.ColorPickerPreference_columns, 3); - size = a.getInt(R.styleable.ColorPickerPreference_colorSize, 2); - sortColors = a.getBoolean(R.styleable.ColorPickerPreference_sortColors, false); - - a.recycle(); - - setWidgetLayoutResource(R.layout.preference_widget_color_swatch); - } - - public ColorPickerPreference(Context context, AttributeSet attrs, int defStyleAttr) { - this(context, attrs, defStyleAttr, 0); - } - - @SuppressLint("RestrictedApi") - public ColorPickerPreference(Context context, AttributeSet attrs) { - this(context, attrs, TypedArrayUtils.getAttr(context, R.attr.dialogPreferenceStyle, - android.R.attr.dialogPreferenceStyle)); - } - - public ColorPickerPreference(Context context) { - this(context, null); - } - - @Override - public void setOnPreferenceChangeListener(OnPreferenceChangeListener listener) { - super.setOnPreferenceChangeListener(listener); - this.listener = listener; - } - - @Override - public void onBindViewHolder(PreferenceViewHolder holder) { - super.onBindViewHolder(holder); - - colorWidget = (ImageView) holder.findViewById(R.id.color_picker_widget); - setColorOnWidget(color); - } - - private void setColorOnWidget(int color) { - if (colorWidget == null) { - return; - } - - Drawable[] colorDrawable = new Drawable[] - {ContextCompat.getDrawable(getContext(), R.drawable.colorpickerpreference_pref_swatch)}; - colorWidget.setImageDrawable(new ColorStateDrawable(colorDrawable, color)); - } - - /** - * Returns the current color. - * - * @return The current color. - */ - public int getColor() { - return color; - } - - /** - * Sets the current color. - * - * @param color The current color. - */ - public void setColor(int color) { - setInternalColor(color, false); - } - - /** - * Returns all of the available colors. - * - * @return The available colors. - */ - public int[] getColors() { - return colors; - } - - /** - * Sets the available colors. - * - * @param colors The available colors. - */ - public void setColors(int[] colors) { - this.colors = colors; - } - - /** - * Returns whether the available colors should be sorted automatically based on their HSV - * values. - * - * @return Whether the available colors should be sorted automatically based on their HSV - * values. - */ - public boolean isSortColors() { - return sortColors; - } - - /** - * Sets whether the available colors should be sorted automatically based on their HSV - * values. The sorting does not modify the order of the original colors supplied via - * {@link #setColors(int[])} or the XML attribute {@code app:colors}. - * - * @param sortColors Whether the available colors should be sorted automatically based on their - * HSV values. - */ - public void setSortColors(boolean sortColors) { - this.sortColors = sortColors; - } - - /** - * Returns the available colors' descriptions that can be used by accessibility services. - * - * @return The available colors' descriptions. - */ - public CharSequence[] getColorDescriptions() { - return colorDescriptions; - } - - /** - * Sets the available colors' descriptions that can be used by accessibility services. - * - * @param colorDescriptions The available colors' descriptions. - */ - public void setColorDescriptions(CharSequence[] colorDescriptions) { - this.colorDescriptions = colorDescriptions; - } - - /** - * Returns the number of columns to be used in the picker dialog for displaying the available - * colors. If the value is less than or equals to 0, the number of columns will be determined - * automatically by the system using FlexboxLayoutManager. - * - * @return The number of columns to be used in the picker dialog. - * @see com.google.android.flexbox.FlexboxLayoutManager - */ - public int getColumns() { - return columns; - } - - /** - * Sets the number of columns to be used in the picker dialog for displaying the available - * colors. If the value is less than or equals to 0, the number of columns will be determined - * automatically by the system using FlexboxLayoutManager. - * - * @param columns The number of columns to be used in the picker dialog. Use 0 to set it to - * 'auto' mode. - * @see com.google.android.flexbox.FlexboxLayoutManager - */ - public void setColumns(int columns) { - this.columns = columns; - } - - /** - * Returns the size of the color swatches in the dialog. It can be either - * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. - * - * @return The size of the color swatches in the dialog. - * @see ColorPickerDialog#SIZE_SMALL - * @see ColorPickerDialog#SIZE_LARGE - */ - @Size - public int getSize() { - return size; - } - - /** - * Sets the size of the color swatches in the dialog. It can be either - * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. - * - * @param size The size of the color swatches in the dialog. It can be either - * {@link ColorPickerDialog#SIZE_SMALL} or {@link ColorPickerDialog#SIZE_LARGE}. - * @see ColorPickerDialog#SIZE_SMALL - * @see ColorPickerDialog#SIZE_LARGE - */ - public void setSize(@Size int size) { - this.size = size; - } - - private void setInternalColor(int color, boolean force) { - int oldColor = getPersistedInt(0); - - boolean changed = oldColor != color; - - if (changed || force) { - this.color = color; - - persistInt(color); - - setColorOnWidget(color); - - if (listener != null) listener.onPreferenceChange(this, color); - notifyChanged(); - } - } - - @Override - protected Object onGetDefaultValue(TypedArray a, int index) { - return a.getString(index); - } - - @Override - protected void onSetInitialValue(boolean restoreValue, Object defaultValueObj) { - final String defaultValue = (String) defaultValueObj; - setInternalColor(restoreValue ? getPersistedInt(0) : (!TextUtils.isEmpty(defaultValue) ? Color.parseColor(defaultValue) : 0), true); - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java deleted file mode 100644 index 964f439ba1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/ColorPickerPreferenceDialogFragmentCompat.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets; - -import android.app.Dialog; -import android.content.DialogInterface; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.preference.PreferenceDialogFragmentCompat; - -import com.takisoft.colorpicker.ColorPickerDialog; -import com.takisoft.colorpicker.OnColorSelectedListener; - -public class ColorPickerPreferenceDialogFragmentCompat extends PreferenceDialogFragmentCompat implements OnColorSelectedListener { - - private int pickedColor; - - public static ColorPickerPreferenceDialogFragmentCompat newInstance(String key) { - ColorPickerPreferenceDialogFragmentCompat fragment = new ColorPickerPreferenceDialogFragmentCompat(); - Bundle b = new Bundle(1); - b.putString(PreferenceDialogFragmentCompat.ARG_KEY, key); - fragment.setArguments(b); - return fragment; - } - - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - ColorPickerPreference pref = getColorPickerPreference(); - - ColorPickerDialog.Params params = new ColorPickerDialog.Params.Builder(getContext()) - .setSelectedColor(pref.getColor()) - .setColors(pref.getColors()) - .setColorContentDescriptions(pref.getColorDescriptions()) - .setSize(pref.getSize()) - .setSortColors(pref.isSortColors()) - .setColumns(pref.getColumns()) - .build(); - - ColorPickerDialog dialog = new ColorPickerDialog(getActivity(), this, params); - dialog.setTitle(pref.getDialogTitle()); - - return dialog; - } - - @Override - public void onDialogClosed(boolean positiveResult) { - ColorPickerPreference preference = getColorPickerPreference(); - - if (positiveResult) { - preference.setColor(pickedColor); - } - } - - @Override - public void onColorSelected(int color) { - this.pickedColor = color; - - super.onClick(getDialog(), DialogInterface.BUTTON_POSITIVE); - } - - ColorPickerPreference getColorPickerPreference() { - return (ColorPickerPreference) getPreference(); - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/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/ReactionRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java index f1cbea16c3..1c05e68bdf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -144,7 +144,6 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecip super(itemView); this.callback = callback; avatar = itemView.findViewById(R.id.reactions_bottom_view_avatar); - avatar.glide = GlideApp.with(itemView); recipient = itemView.findViewById(R.id.reactions_bottom_view_recipient_name); remove = itemView.findViewById(R.id.reactions_bottom_view_recipient_remove); } 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 00f5d72c7b..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 @@ -23,55 +38,42 @@ import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.SessionJobDatabase import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.Storage 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(): 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, @@ -81,14 +83,36 @@ class DefaultConversationRepository @Inject constructor( private val mmsDb: MmsDatabase, private val mmsSmsDb: MmsSmsDatabase, private val recipientDb: RecipientDatabase, + private val storage: Storage, private val lokiMessageDb: LokiMessageDatabase, - private val sessionJobDb: SessionJobDatabase + private val sessionJobDb: SessionJobDatabase, + private val 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() @@ -98,31 +122,41 @@ class DefaultConversationRepository @Inject constructor( override fun getDraft(threadId: Long): String? { val drafts = draftDb.getDrafts(threadId) - draftDb.clearDrafts(threadId) return drafts.find { it.type == DraftDatabase.Draft.TEXT }?.value } + override fun clearDrafts(threadId: Long) { + draftDb.clearDrafts(threadId) + } + override fun inviteContacts(threadId: Long, contacts: List<Recipient>) { val openGroup = lokiThreadDb.getOpenGroupChat(threadId) ?: return for (contact in contacts) { val message = VisibleMessage() - message.sentTimestamp = System.currentTimeMillis() - val openGroupInvitation = OpenGroupInvitation() - openGroupInvitation.name = openGroup.name - openGroupInvitation.url = openGroup.joinURL + message.sentTimestamp = SnodeAPI.nowWithOffset + 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) } } + // This assumes that recipient.isContactRecipient is true override fun setBlocked(recipient: Recipient, blocked: Boolean) { - recipientDb.setBlocked(recipient, blocked) + storage.setBlocked(listOf(recipient), blocked) } override fun deleteLocally(recipient: Recipient, message: MessageRecord) { @@ -134,8 +168,17 @@ 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) { - recipientDb.setApproved(recipient, isApproved) + storage.setRecipientApproved(recipient, isApproved) } override suspend fun deleteForEveryone( @@ -146,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() @@ -168,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) } } @@ -175,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( @@ -200,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) @@ -234,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)) @@ -246,29 +307,31 @@ class DefaultConversationRepository @Inject constructor( override suspend fun deleteThread(threadId: Long): ResultOf<Unit> { sessionJobDb.cancelPendingMessageSendJobs(threadId) - threadDb.deleteConversation(threadId) + storage.deleteConversation(threadId) return ResultOf.Success(Unit) } override suspend fun deleteMessageRequest(thread: ThreadRecord): ResultOf<Unit> { sessionJobDb.cancelPendingMessageSendJobs(thread.threadId) - threadDb.deleteConversation(thread.threadId) + storage.deleteConversation(thread.threadId) return ResultOf.Success(Unit) } - override suspend fun clearAllMessageRequests(): ResultOf<Unit> { + override suspend fun clearAllMessageRequests(block: Boolean): ResultOf<Unit> { threadDb.readerFor(threadDb.unapprovedConversationList).use { reader -> while (reader.next != null) { deleteMessageRequest(reader.current) + val recipient = reader.current.recipient + if (block) { setBlocked(recipient, true) } } } return ResultOf.Success(Unit) } override suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): ResultOf<Unit> = suspendCoroutine { continuation -> - recipientDb.setApproved(recipient, true) + storage.setRecipientApproved(recipient, true) val message = MessageRequestResponse(true) - MessageSender.send(message, Destination.from(recipient.address)) + MessageSender.send(message, Destination.from(recipient.address), isSyncMessage = recipient.isLocalNumber) .success { threadDb.setHasSent(threadId, true) continuation.resume(ResultOf.Success(Unit)) @@ -279,16 +342,14 @@ class DefaultConversationRepository @Inject constructor( override fun declineMessageRequest(threadId: Long) { sessionJobDb.cancelPendingMessageSendJobs(threadId) - threadDb.deleteConversation(threadId) + storage.deleteConversation(threadId) } override fun hasReceived(threadId: Long): Boolean { 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 a33f4dd11c..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) { @@ -301,10 +276,10 @@ public class SearchRepository { Recipient conversationRecipient = Recipient.from(context, conversationAddress, false); Recipient messageRecipient = Recipient.from(context, messageAddress, false); String body = cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.SNIPPET)); - long receivedMs = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_RECEIVED)); + long sentMs = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_SENT)); long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.THREAD_ID)); - return new MessageResult(conversationRecipient, messageRecipient, body, threadId, receivedMs); + return new MessageResult(conversationRecipient, messageRecipient, body, threadId, sentMs); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/model/MessageResult.java b/app/src/main/java/org/thoughtcrime/securesms/search/model/MessageResult.java index 4523ab364b..58e3f1a69a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/model/MessageResult.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/model/MessageResult.java @@ -13,18 +13,18 @@ public class MessageResult { public final Recipient messageRecipient; public final String bodySnippet; public final long threadId; - public final long receivedTimestampMs; + public final long sentTimestampMs; public MessageResult(@NonNull Recipient conversationRecipient, @NonNull Recipient messageRecipient, @NonNull String bodySnippet, long threadId, - long receivedTimestampMs) + long sentTimestampMs) { this.conversationRecipient = conversationRecipient; this.messageRecipient = messageRecipient; this.bodySnippet = bodySnippet; this.threadId = threadId; - this.receivedTimestampMs = receivedTimestampMs; + this.sentTimestampMs = sentTimestampMs; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java b/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java index 13701300b5..0516dc2856 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java @@ -6,14 +6,12 @@ import android.content.IntentFilter; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.drawable.Icon; -import android.os.Build; import android.os.Bundle; import android.os.Parcel; import android.service.chooser.ChooserTarget; import android.service.chooser.ChooserTargetService; import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.Log; @@ -28,7 +26,6 @@ import java.util.LinkedList; import java.util.List; import java.util.concurrent.ExecutionException; -@RequiresApi(api = Build.VERSION_CODES.M) public class DirectShareService extends ChooserTargetService { private static final String TAG = DirectShareService.class.getSimpleName(); @@ -40,53 +37,50 @@ public class DirectShareService extends ChooserTargetService { List<ChooserTarget> results = new LinkedList<>(); ComponentName componentName = new ComponentName(this, ShareActivity.class); ThreadDatabase threadDatabase = DatabaseComponent.get(this).threadDatabase(); - Cursor cursor = threadDatabase.getDirectShareList(); - try { - ThreadDatabase.Reader reader = threadDatabase.readerFor(cursor); - ThreadRecord record; + try (Cursor cursor = threadDatabase.getDirectShareList()) { + ThreadDatabase.Reader reader = threadDatabase.readerFor(cursor); + ThreadRecord record; - while ((record = reader.getNext()) != null && results.size() < 10) { - Recipient recipient = Recipient.from(this, record.getRecipient().getAddress(), false); - String name = recipient.toShortString(); + while ((record = reader.getNext()) != null && results.size() < 10) { + Recipient recipient = Recipient.from(this, record.getRecipient().getAddress(), false); + String name = recipient.toShortString(); - Bitmap avatar; + Bitmap avatar; + + if (recipient.getContactPhoto() != null) { + try { + avatar = GlideApp.with(this) + .asBitmap() + .load(recipient.getContactPhoto()) + .circleCrop() + .submit(getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width), + getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width)) + .get(); + } catch (InterruptedException | ExecutionException e) { + Log.w(TAG, e); + avatar = getFallbackDrawable(recipient); + } + } else { + avatar = getFallbackDrawable(recipient); + } + + Parcel parcel = Parcel.obtain(); + parcel.writeParcelable(recipient.getAddress(), 0); + + Bundle bundle = new Bundle(); + bundle.putLong(ShareActivity.EXTRA_THREAD_ID, record.getThreadId()); + bundle.putByteArray(ShareActivity.EXTRA_ADDRESS_MARSHALLED, parcel.marshall()); + bundle.putInt(ShareActivity.EXTRA_DISTRIBUTION_TYPE, record.getDistributionType()); + bundle.setClassLoader(getClassLoader()); + + results.add(new ChooserTarget(name, Icon.createWithBitmap(avatar), 1.0f, componentName, bundle)); + parcel.recycle(); - if (recipient.getContactPhoto() != null) { - try { - avatar = GlideApp.with(this) - .asBitmap() - .load(recipient.getContactPhoto()) - .circleCrop() - .submit(getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width), - getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width)) - .get(); - } catch (InterruptedException | ExecutionException e) { - Log.w(TAG, e); - avatar = getFallbackDrawable(recipient); - } - } else { - avatar = getFallbackDrawable(recipient); } - Parcel parcel = Parcel.obtain(); - parcel.writeParcelable(recipient.getAddress(), 0); - - Bundle bundle = new Bundle(); - bundle.putLong(ShareActivity.EXTRA_THREAD_ID, record.getThreadId()); - bundle.putByteArray(ShareActivity.EXTRA_ADDRESS_MARSHALLED, parcel.marshall()); - bundle.putInt(ShareActivity.EXTRA_DISTRIBUTION_TYPE, record.getDistributionType()); - bundle.setClassLoader(getClassLoader()); - - results.add(new ChooserTarget(name, Icon.createWithBitmap(avatar), 1.0f, componentName, bundle)); - parcel.recycle(); - + return results; } - - return results; - } finally { - if (cursor != null) cursor.close(); - } } private Bitmap getFallbackDrawable(@NonNull Recipient recipient) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ExpirationListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/ExpirationListener.java index 4a83707ddd..a0ef945c9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpirationListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ExpirationListener.java @@ -17,7 +17,7 @@ public class ExpirationListener extends BroadcastReceiver { public static void setAlarm(Context context, long waitTimeMillis) { Intent intent = new Intent(context, ExpirationListener.class); - PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, 0); + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE); AlarmManager alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE); alarmManager.cancel(pendingIntent); 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 b6d2e2bc57..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/ExpiringMessageManager.java +++ /dev/null @@ -1,277 +0,0 @@ -package org.thoughtcrime.securesms.service; - -import android.content.Context; - -import org.jetbrains.annotations.NotNull; -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.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 Context context; - - public ExpiringMessageManager(Context context) { - this.context = context.getApplicationContext(); - this.smsDatabase = DatabaseComponent.get(context).smsDatabase(); - this.mmsDatabase = DatabaseComponent.get(context).mmsDatabase(); - - 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) { - DatabaseComponent.get(context).smsDatabase().deleteMessage(message.getId()); - } - } - - private void insertIncomingExpirationTimerMessage(ExpirationTimerUpdate message) { - MmsDatabase database = DatabaseComponent.get(context).mmsDatabase(); - - 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); - } - - IncomingMediaMessage mediaMessage = new IncomingMediaMessage(address, sentTimestamp, -1, - duration * 1000L, true, - false, - false, - Optional.absent(), - groupInfo, - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent()); - //insert the timer update message - database.insertSecureDecryptedMessageInbox(mediaMessage, -1, true, true); - - //set the timer to the conversation - DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); - - } catch (IOException | MmsException ioe) { - Log.e("Loki", "Failed to insert expiration update message."); - } - } - - private void insertOutgoingExpirationTimerMessage(ExpirationTimerUpdate message) { - MmsDatabase database = DatabaseComponent.get(context).mmsDatabase(); - - Long sentTimestamp = message.getSentTimestamp(); - String groupId = message.getGroupPublicKey(); - int duration = message.getDuration(); - - Address address = Address.fromSerialized((message.getSyncTarget() != null && !message.getSyncTarget().isEmpty()) ? message.getSyncTarget() : message.getRecipient()); - Recipient recipient = Recipient.from(context, address, false); - - try { - OutgoingExpirationUpdateMessage timerUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, sentTimestamp, duration * 1000L, groupId); - database.insertSecureDecryptedMessageOutbox(timerUpdateMessage, -1, sentTimestamp, true); - - if (groupId != null) { - // we need the group ID as recipient for setExpireMessages below - recipient = Recipient.from(context, Address.fromSerialized(GroupUtil.doubleEncodeGroupID(groupId)), false); - } - //set the timer to the conversation - DatabaseComponent.get(context).recipientDatabase().setExpireMessages(recipient, duration); - - } catch (MmsException | IOException ioe) { - Log.e("Loki", "Failed to insert expiration update message."); - } - } - - @Override - public void disableExpirationTimer(@NotNull ExpirationTimerUpdate message) { - setExpirationTimer(message); - } - - @Override - public void startAnyExpiration(long timestamp, @NotNull String author) { - MessageRecord messageRecord = DatabaseComponent.get(context).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/GenericForegroundService.java b/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java index 0581883c5e..52a259d5be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java @@ -6,6 +6,7 @@ import android.app.Service; import android.content.Context; import android.content.Intent; import android.os.IBinder; + import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -13,9 +14,9 @@ import androidx.core.app.NotificationCompat; import androidx.core.content.ContextCompat; import org.session.libsignal.utilities.Log; +import org.session.libsignal.utilities.guava.Preconditions; import org.thoughtcrime.securesms.home.HomeActivity; import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.session.libsignal.utilities.guava.Preconditions; import network.loki.messenger.R; @@ -87,10 +88,10 @@ public class GenericForegroundService extends Service { } private void postObligatoryForegroundNotification(String title, String channelId, @DrawableRes int iconRes) { - startForeground(NOTIFICATION_ID, new NotificationCompat.Builder(this, channelId) + startForeground(NOTIFICATION_ID, new NotificationCompat.Builder(this, channelId) .setSmallIcon(iconRes) .setContentTitle(title) - .setContentIntent(PendingIntent.getActivity(this, 0, new Intent(this, HomeActivity.class), 0)) + .setContentIntent(PendingIntent.getActivity(this, 0, new Intent(this, HomeActivity.class), PendingIntent.FLAG_IMMUTABLE)) .build()); } 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 9e79b93d60..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,22 +25,25 @@ 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; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; +import org.session.libsession.utilities.ServiceUtil; +import org.session.libsession.utilities.TextSecurePreferences; +import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.DatabaseUpgradeActivity; import org.thoughtcrime.securesms.DummyActivity; -import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.home.HomeActivity; import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.session.libsession.utilities.ServiceUtil; -import org.session.libsession.utilities.TextSecurePreferences; import java.util.concurrent.TimeUnit; @@ -249,24 +252,28 @@ 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() { Intent intent = new Intent(this, KeyCachingService.class); intent.setAction(PASSPHRASE_EXPIRED_EVENT); - return PendingIntent.getService(getApplicationContext(), 0, intent, 0); + return PendingIntent.getService(getApplicationContext(), 0, intent, PendingIntent.FLAG_IMMUTABLE); } private PendingIntent buildLaunchIntent() { Intent intent = new Intent(this, HomeActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - return PendingIntent.getActivity(getApplicationContext(), 0, intent, 0); + return PendingIntent.getActivity(getApplicationContext(), 0, intent, PendingIntent.FLAG_IMMUTABLE); } private static PendingIntent buildExpirationPendingIntent(@NonNull Context context) { Intent expirationIntent = new Intent(PASSPHRASE_EXPIRED_EVENT, null, context, KeyCachingService.class); - return PendingIntent.getService(context, 0, expirationIntent, 0); + return PendingIntent.getService(context, 0, expirationIntent, PendingIntent.FLAG_IMMUTABLE); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/LocalBackupListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/LocalBackupListener.java deleted file mode 100644 index 0b1b3f8423..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/LocalBackupListener.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.thoughtcrime.securesms.service; - -import android.content.Context; -import android.content.Intent; - -import org.thoughtcrime.securesms.ApplicationContext; -import org.thoughtcrime.securesms.jobs.LocalBackupJob; -import org.session.libsession.utilities.TextSecurePreferences; - -import java.util.concurrent.TimeUnit; - -public class LocalBackupListener extends PersistentAlarmManagerListener { - - private static final long INTERVAL = TimeUnit.DAYS.toMillis(1); - - @Override - protected long getNextScheduledExecutionTime(Context context) { - return TextSecurePreferences.getNextBackupTime(context); - } - - @Override - protected long onAlarm(Context context, long scheduledTime) { - if (TextSecurePreferences.isBackupEnabled(context)) { - ApplicationContext.getInstance(context).getJobManager().add(new LocalBackupJob()); - } - - long nextTime = System.currentTimeMillis() + INTERVAL; - TextSecurePreferences.setNextBackupTime(context, nextTime); - - return nextTime; - } - - public static void schedule(Context context) { - if (TextSecurePreferences.isBackupEnabled(context)) { - new LocalBackupListener().onReceive(context, new Intent()); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java index 4091d953af..f24906c5e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java @@ -6,6 +6,7 @@ import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; + import org.session.libsignal.utilities.Log; public abstract class PersistentAlarmManagerListener extends BroadcastReceiver { @@ -21,7 +22,7 @@ public abstract class PersistentAlarmManagerListener extends BroadcastReceiver { long scheduledTime = getNextScheduledExecutionTime(context); AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); Intent alarmIntent = new Intent(context, getClass()); - PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, 0); + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_IMMUTABLE); if (System.currentTimeMillis() >= scheduledTime) { scheduledTime = onAlarm(context, scheduledTime); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java b/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java index 0f5a3f17a6..a56bc8c0de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java @@ -9,6 +9,7 @@ import android.widget.Toast; import network.loki.messenger.R; import org.session.libsession.messaging.messages.visible.VisibleMessage; +import org.session.libsession.snode.SnodeAPI; import org.session.libsession.utilities.Address; import org.session.libsignal.utilities.Log; import org.session.libsession.messaging.sending_receiving.MessageSender; @@ -50,7 +51,7 @@ public class QuickResponseService extends IntentService { if (!TextUtils.isEmpty(content)) { VisibleMessage message = new VisibleMessage(); message.setText(content); - message.setSentTimestamp(System.currentTimeMillis()); + message.setSentTimestamp(SnodeAPI.getNowWithOffset()); MessageSender.send(message, Address.fromExternal(this, number)); } } catch (URISyntaxException e) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java index 323617d81d..eea6ba00f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java @@ -12,21 +12,22 @@ import android.net.Uri; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; -import org.session.libsignal.utilities.Log; -import network.loki.messenger.R; -import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.util.FileProviderUtil; import org.session.libsession.utilities.FileUtils; -import org.session.libsignal.utilities.Hex; import org.session.libsession.utilities.ServiceUtil; import org.session.libsession.utilities.TextSecurePreferences; +import org.session.libsignal.utilities.Hex; +import org.session.libsignal.utilities.Log; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.util.FileProviderUtil; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.security.MessageDigest; +import network.loki.messenger.R; + public class UpdateApkReadyListener extends BroadcastReceiver { private static final String TAG = UpdateApkReadyListener.class.getSimpleName(); @@ -61,7 +62,7 @@ public class UpdateApkReadyListener extends BroadcastReceiver { intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.setData(uri); - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE); Notification notification = new NotificationCompat.Builder(context, NotificationChannels.APP_UPDATES) .setOngoing(true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkRefreshListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkRefreshListener.java deleted file mode 100644 index 187713df95..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkRefreshListener.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.thoughtcrime.securesms.service; - - -import android.content.Context; -import android.content.Intent; -import org.session.libsignal.utilities.Log; - -import org.thoughtcrime.securesms.ApplicationContext; -import network.loki.messenger.BuildConfig; -import org.thoughtcrime.securesms.jobs.UpdateApkJob; -import org.session.libsession.utilities.TextSecurePreferences; - -import java.util.concurrent.TimeUnit; - -public class UpdateApkRefreshListener extends PersistentAlarmManagerListener { - - private static final String TAG = UpdateApkRefreshListener.class.getSimpleName(); - - private static final long INTERVAL = TimeUnit.HOURS.toMillis(6); - - @Override - protected long getNextScheduledExecutionTime(Context context) { - return TextSecurePreferences.getUpdateApkRefreshTime(context); - } - - @Override - protected long onAlarm(Context context, long scheduledTime) { - Log.i(TAG, "onAlarm..."); - - if (scheduledTime != 0 && BuildConfig.PLAY_STORE_DISABLED) { - Log.i(TAG, "Queueing APK update job..."); - ApplicationContext.getInstance(context) - .getJobManager() - .add(new UpdateApkJob()); - } - - long newTime = System.currentTimeMillis() + INTERVAL; - TextSecurePreferences.setUpdateApkRefreshTime(context, newTime); - - return newTime; - } - - public static void schedule(Context context) { - new UpdateApkRefreshListener().onReceive(context, new Intent()); - } - -} 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 0f10a93b0b..cfe1f38f58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt @@ -1,19 +1,24 @@ package org.thoughtcrime.securesms.service -import android.app.Service +import android.app.ForegroundServiceStartNotAllowedException import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.IntentFilter +import android.content.pm.PackageManager import android.media.AudioManager +import android.os.Build import android.os.IBinder import android.os.ResultReceiver import android.telephony.PhoneStateListener +import android.telephony.PhoneStateListener.LISTEN_NONE import android.telephony.TelephonyManager import androidx.core.content.ContextCompat import androidx.core.os.bundleOf +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager import dagger.hilt.android.AndroidEntryPoint import org.session.libsession.messaging.calls.CallMessageType @@ -22,36 +27,20 @@ import org.session.libsession.utilities.FutureTaskListener import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.calls.WebRtcCallActivity +import org.thoughtcrime.securesms.notifications.BackgroundPollWorker import org.thoughtcrime.securesms.util.CallNotificationBuilder import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_ESTABLISHED import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_INCOMING_CONNECTING import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_INCOMING_PRE_OFFER import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_INCOMING_RINGING import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_OUTGOING_RINGING -import org.thoughtcrime.securesms.webrtc.AudioManagerCommand -import org.thoughtcrime.securesms.webrtc.CallManager -import org.thoughtcrime.securesms.webrtc.CallViewModel -import org.thoughtcrime.securesms.webrtc.HangUpRtcOnPstnCallAnsweredListener -import org.thoughtcrime.securesms.webrtc.IncomingPstnCallReceiver -import org.thoughtcrime.securesms.webrtc.NetworkChangeReceiver -import org.thoughtcrime.securesms.webrtc.PeerConnectionException -import org.thoughtcrime.securesms.webrtc.PowerButtonReceiver -import org.thoughtcrime.securesms.webrtc.ProximityLockRelease -import org.thoughtcrime.securesms.webrtc.UncaughtExceptionHandlerManager -import org.thoughtcrime.securesms.webrtc.WiredHeadsetStateReceiver +import org.thoughtcrime.securesms.webrtc.* import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger import org.thoughtcrime.securesms.webrtc.data.Event import org.thoughtcrime.securesms.webrtc.locks.LockManager -import org.webrtc.DataChannel -import org.webrtc.IceCandidate -import org.webrtc.MediaStream -import org.webrtc.PeerConnection -import org.webrtc.PeerConnection.IceConnectionState.CONNECTED -import org.webrtc.PeerConnection.IceConnectionState.DISCONNECTED -import org.webrtc.PeerConnection.IceConnectionState.FAILED -import org.webrtc.RtpReceiver -import org.webrtc.SessionDescription -import java.util.UUID +import org.webrtc.* +import org.webrtc.PeerConnection.IceConnectionState.* +import java.util.* import java.util.concurrent.ExecutionException import java.util.concurrent.Executors import java.util.concurrent.ScheduledFuture @@ -60,7 +49,7 @@ import javax.inject.Inject import org.thoughtcrime.securesms.webrtc.data.State as CallState @AndroidEntryPoint -class WebRtcCallService: Service(), CallManager.WebRtcListener { +class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { companion object { @@ -108,62 +97,82 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { private const val RECONNECT_SECONDS = 5L private const val MAX_RECONNECTS = 5 - fun cameraEnabled(context: Context, enabled: Boolean) = Intent(context, WebRtcCallService::class.java) + fun cameraEnabled(context: Context, enabled: Boolean) = + Intent(context, WebRtcCallService::class.java) .setAction(ACTION_SET_MUTE_VIDEO) .putExtra(EXTRA_MUTE, !enabled) fun flipCamera(context: Context) = Intent(context, WebRtcCallService::class.java) - .setAction(ACTION_FLIP_CAMERA) + .setAction(ACTION_FLIP_CAMERA) fun acceptCallIntent(context: Context) = Intent(context, WebRtcCallService::class.java) - .setAction(ACTION_ANSWER_CALL) + .setAction(ACTION_ANSWER_CALL) - fun microphoneIntent(context: Context, enabled: Boolean) = Intent(context, WebRtcCallService::class.java) - .setAction(ACTION_SET_MUTE_AUDIO) - .putExtra(EXTRA_MUTE, !enabled) + fun microphoneIntent(context: Context, enabled: Boolean) = + Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_SET_MUTE_AUDIO) + .putExtra(EXTRA_MUTE, !enabled) - fun createCall(context: Context, recipient: Recipient) = Intent(context, WebRtcCallService::class.java) + fun createCall(context: Context, recipient: Recipient) = + Intent(context, WebRtcCallService::class.java) .setAction(ACTION_OUTGOING_CALL) .putExtra(EXTRA_RECIPIENT_ADDRESS, recipient.address) - fun incomingCall(context: Context, address: Address, sdp: String, callId: UUID, callTime: Long) = - Intent(context, WebRtcCallService::class.java) - .setAction(ACTION_INCOMING_RING) - .putExtra(EXTRA_RECIPIENT_ADDRESS, address) - .putExtra(EXTRA_CALL_ID, callId) - .putExtra(EXTRA_REMOTE_DESCRIPTION, sdp) - .putExtra(EXTRA_TIMESTAMP, callTime) + fun incomingCall( + context: Context, + address: Address, + sdp: String, + callId: UUID, + callTime: Long + ) = + Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_INCOMING_RING) + .putExtra(EXTRA_RECIPIENT_ADDRESS, address) + .putExtra(EXTRA_CALL_ID, callId) + .putExtra(EXTRA_REMOTE_DESCRIPTION, sdp) + .putExtra(EXTRA_TIMESTAMP, callTime) fun incomingAnswer(context: Context, address: Address, sdp: String, callId: UUID) = - Intent(context, WebRtcCallService::class.java) - .setAction(ACTION_RESPONSE_MESSAGE) - .putExtra(EXTRA_RECIPIENT_ADDRESS, address) - .putExtra(EXTRA_CALL_ID, callId) - .putExtra(EXTRA_REMOTE_DESCRIPTION, sdp) + Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_RESPONSE_MESSAGE) + .putExtra(EXTRA_RECIPIENT_ADDRESS, address) + .putExtra(EXTRA_CALL_ID, callId) + .putExtra(EXTRA_REMOTE_DESCRIPTION, sdp) fun preOffer(context: Context, address: Address, callId: UUID, callTime: Long) = - Intent(context, WebRtcCallService::class.java) - .setAction(ACTION_PRE_OFFER) - .putExtra(EXTRA_RECIPIENT_ADDRESS, address) - .putExtra(EXTRA_CALL_ID, callId) - .putExtra(EXTRA_TIMESTAMP, callTime) + Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_PRE_OFFER) + .putExtra(EXTRA_RECIPIENT_ADDRESS, address) + .putExtra(EXTRA_CALL_ID, callId) + .putExtra(EXTRA_TIMESTAMP, callTime) - fun iceCandidates(context: Context, address: Address, iceCandidates: List<IceCandidate>, callId: UUID) = - Intent(context, WebRtcCallService::class.java) - .setAction(ACTION_ICE_MESSAGE) - .putExtra(EXTRA_CALL_ID, callId) - .putExtra(EXTRA_ICE_SDP, iceCandidates.map(IceCandidate::sdp).toTypedArray()) - .putExtra(EXTRA_ICE_SDP_LINE_INDEX, iceCandidates.map(IceCandidate::sdpMLineIndex).toIntArray()) - .putExtra(EXTRA_ICE_SDP_MID, iceCandidates.map(IceCandidate::sdpMid).toTypedArray()) - .putExtra(EXTRA_RECIPIENT_ADDRESS, address) + fun iceCandidates( + context: Context, + address: Address, + iceCandidates: List<IceCandidate>, + callId: UUID + ) = + Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_ICE_MESSAGE) + .putExtra(EXTRA_CALL_ID, callId) + .putExtra(EXTRA_ICE_SDP, iceCandidates.map(IceCandidate::sdp).toTypedArray()) + .putExtra( + EXTRA_ICE_SDP_LINE_INDEX, + iceCandidates.map(IceCandidate::sdpMLineIndex).toIntArray() + ) + .putExtra(EXTRA_ICE_SDP_MID, iceCandidates.map(IceCandidate::sdpMid).toTypedArray()) + .putExtra(EXTRA_RECIPIENT_ADDRESS, address) - fun denyCallIntent(context: Context) = Intent(context, WebRtcCallService::class.java).setAction(ACTION_DENY_CALL) + fun denyCallIntent(context: Context) = + Intent(context, WebRtcCallService::class.java).setAction(ACTION_DENY_CALL) - fun remoteHangupIntent(context: Context, callId: UUID) = Intent(context, WebRtcCallService::class.java) + fun remoteHangupIntent(context: Context, callId: UUID) = + Intent(context, WebRtcCallService::class.java) .setAction(ACTION_REMOTE_HANGUP) .putExtra(EXTRA_CALL_ID, callId) - fun hangupIntent(context: Context) = Intent(context, WebRtcCallService::class.java).setAction(ACTION_LOCAL_HANGUP) + fun hangupIntent(context: Context) = + Intent(context, WebRtcCallService::class.java).setAction(ACTION_LOCAL_HANGUP) fun sendAudioManagerCommand(context: Context, command: AudioManagerCommand) { val intent = Intent(context, WebRtcCallService::class.java) @@ -173,22 +182,21 @@ class WebRtcCallService: Service(), 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) } @JvmStatic fun isCallActive(context: Context, resultReceiver: ResultReceiver) { val intent = Intent(context, WebRtcCallService::class.java) - .setAction(ACTION_IS_IN_CALL_QUERY) - .putExtra(EXTRA_RESULT_RECEIVER, resultReceiver) + .setAction(ACTION_IS_IN_CALL_QUERY) + .putExtra(EXTRA_RESULT_RECEIVER, resultReceiver) context.startService(intent) } } - @Inject lateinit var callManager: CallManager + @Inject + lateinit var callManager: CallManager private var wantsToAnswer = false private var currentTimeouts = 0 @@ -199,8 +207,17 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { private val lockManager by lazy { LockManager(this) } private val serviceExecutor = Executors.newSingleThreadExecutor() private val timeoutExecutor = Executors.newScheduledThreadPool(1) - private val hangupOnCallAnswered = HangUpRtcOnPstnCallAnsweredListener { - ContextCompat.startForegroundService(this, hangupIntent(this)) + + private val hangupOnCallAnswered by lazy { + HangUpRtcOnPstnCallAnsweredListener { + ContextCompat.startForegroundService(this, hangupIntent(this)) + } + } + + private val hangupTelephonyCallback by lazy { + HangUpRtcTelephonyCallback { + ContextCompat.startForegroundService(this, hangupIntent(this)) + } } private var networkChangedReceiver: NetworkChangeReceiver? = null @@ -222,7 +239,10 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { scheduledReconnect?.cancel(false) scheduledTimeout = null scheduledReconnect = null - stopForeground(true) + + lifecycleScope.launchWhenCreated { + stopForeground(true) + } } private fun isSameCall(intent: Intent): Boolean { @@ -237,7 +257,9 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { private fun isIdle() = callManager.isIdle() - override fun onBind(intent: Intent?): IBinder? = null + override fun onBind(intent: Intent): IBinder? { + return super.onBind(intent) + } override fun onHangup() { serviceExecutor.execute { @@ -256,9 +278,12 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { if (intent == null || intent.action == null) return START_NOT_STICKY serviceExecutor.execute { val action = intent.action - Log.i("Loki", "Handling ${intent.action}") + val callId = ((intent.getSerializableExtra(EXTRA_CALL_ID) as? UUID)?.toString() ?: "No callId") + Log.i("Loki", "Handling ${intent.action} for call: ${callId}") when { - action == ACTION_INCOMING_RING && isSameCall(intent) && callManager.currentConnectionState == CallState.Reconnecting -> handleNewOffer(intent) + action == ACTION_INCOMING_RING && isSameCall(intent) && callManager.currentConnectionState == CallState.Reconnecting -> handleNewOffer( + intent + ) action == ACTION_PRE_OFFER && isIdle() -> handlePreOffer(intent) action == ACTION_INCOMING_RING && isBusy(intent) -> handleBusyCall(intent) action == ACTION_INCOMING_RING && isPreOffer() -> handleIncomingRing(intent) @@ -272,7 +297,9 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { action == ACTION_FLIP_CAMERA -> handleSetCameraFlip(intent) action == ACTION_WIRED_HEADSET_CHANGE -> handleWiredHeadsetChanged(intent) action == ACTION_SCREEN_OFF -> handleScreenOffChange(intent) - action == ACTION_RESPONSE_MESSAGE && isSameCall(intent) && callManager.currentConnectionState == CallState.Reconnecting -> handleResponseMessage(intent) + action == ACTION_RESPONSE_MESSAGE && isSameCall(intent) && callManager.currentConnectionState == CallState.Reconnecting -> handleResponseMessage( + intent + ) action == ACTION_RESPONSE_MESSAGE -> handleResponseMessage(intent) action == ACTION_ICE_MESSAGE -> handleRemoteIceCandidate(intent) action == ACTION_ICE_CONNECTED -> handleIceConnected(intent) @@ -293,8 +320,15 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { registerIncomingPstnCallReceiver() registerWiredHeadsetStateReceiver() registerWantsToAnswerReceiver() - getSystemService(TelephonyManager::class.java) - .listen(hangupOnCallAnswered, PhoneStateListener.LISTEN_CALL_STATE) + if (checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + getSystemService(TelephonyManager::class.java) + .listen(hangupOnCallAnswered, PhoneStateListener.LISTEN_CALL_STATE) + } else { + getSystemService(TelephonyManager::class.java) + .registerTelephonyCallback(serviceExecutor, hangupTelephonyCallback) + } + } registerUncaughtExceptionHandler() networkChangedReceiver = NetworkChangeReceiver(::networkChange) networkChangedReceiver!!.register(this) @@ -318,7 +352,8 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { } } wantsToAnswerReceiver = receiver - LocalBroadcastManager.getInstance(this).registerReceiver(receiver, IntentFilter(ACTION_WANTS_TO_ANSWER)) + LocalBroadcastManager.getInstance(this) + .registerReceiver(receiver, IntentFilter(ACTION_WANTS_TO_ANSWER)) } private fun registerWiredHeadsetStateReceiver() { @@ -333,13 +368,19 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { insertMissedCall(recipient, false) if (callState == CallState.Idle) { - stopForeground(true) + lifecycleScope.launchWhenCreated { + stopForeground(true) + } } } private fun handleUpdateAudio(intent: Intent) { val audioCommand = intent.getParcelableExtra<AudioManagerCommand>(EXTRA_AUDIO_COMMAND)!! - if (callManager.currentConnectionState !in arrayOf(CallState.Connected, *CallState.PENDING_CONNECTION_STATES)) { + if (callManager.currentConnectionState !in arrayOf( + CallState.Connected, + *CallState.PENDING_CONNECTION_STATES + ) + ) { Log.w(TAG, "handling audio command not in call") return } @@ -377,6 +418,11 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { callManager.initializeAudioForCall() callManager.startIncomingRinger() callManager.setAudioEnabled(true) + + BackgroundPollWorker.scheduleOnce( + this, + arrayOf(BackgroundPollWorker.Targets.DMS) + ) } } @@ -419,8 +465,15 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { callManager.initializeAudioForCall() callManager.startOutgoingRinger(OutgoingRinger.Type.RINGING) setCallInProgressNotification(TYPE_OUTGOING_RINGING, callManager.recipient) - callManager.insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_OUTGOING) - scheduledTimeout = timeoutExecutor.schedule(TimeoutRunnable(callId, this), TIMEOUT_SECONDS, TimeUnit.SECONDS) + callManager.insertCallMessage( + recipient.address.serialize(), + CallMessageType.CALL_OUTGOING + ) + scheduledTimeout = timeoutExecutor.schedule( + TimeoutRunnable(callId, this), + TIMEOUT_SECONDS, + TimeUnit.SECONDS + ) callManager.setAudioEnabled(true) val expectedState = callManager.currentConnectionState @@ -429,15 +482,21 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { try { val offerFuture = callManager.onOutgoingCall(this) offerFuture.fail { e -> - if (isConsistentState(expectedState, expectedCallId, callManager.currentConnectionState, callManager.callId)) { - Log.e(TAG,e) + if (isConsistentState( + expectedState, + expectedCallId, + callManager.currentConnectionState, + callManager.callId + ) + ) { + Log.e(TAG, e) callManager.postViewModelState(CallViewModel.State.NETWORK_FAILURE) callManager.postConnectionError() terminate() } } } catch (e: Exception) { - Log.e(TAG,e) + Log.e(TAG, e) callManager.postConnectionError() terminate() } @@ -445,9 +504,9 @@ class WebRtcCallService: Service(), 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) { @@ -465,9 +524,7 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { insertMissedCall(recipient, true) terminate() } - if (didHangup) { - return - } + if (didHangup) { return } } callManager.postConnectionEvent(Event.SendAnswer) { @@ -476,7 +533,11 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { callManager.silenceIncomingRinger() callManager.postViewModelState(CallViewModel.State.CALL_INCOMING) - scheduledTimeout = timeoutExecutor.schedule(TimeoutRunnable(callId, this), TIMEOUT_SECONDS, TimeUnit.SECONDS) + scheduledTimeout = timeoutExecutor.schedule( + TimeoutRunnable(callId, this), + TIMEOUT_SECONDS, + TimeUnit.SECONDS + ) callManager.initializeAudioForCall() callManager.initializeVideo(this) @@ -487,7 +548,13 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { try { val answerFuture = callManager.onIncomingCall(this) answerFuture.fail { e -> - if (isConsistentState(expectedState,expectedCallId, callManager.currentConnectionState, callManager.callId)) { + if (isConsistentState( + expectedState, + expectedCallId, + callManager.currentConnectionState, + callManager.callId + ) + ) { Log.e(TAG, e) insertMissedCall(recipient, true) callManager.postConnectionError() @@ -497,7 +564,7 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { lockManager.updatePhoneState(LockManager.PhoneState.PROCESSING) callManager.setAudioEnabled(true) } catch (e: Exception) { - Log.e(TAG,e) + Log.e(TAG, e) callManager.postConnectionError() terminate() } @@ -518,6 +585,9 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { private fun handleRemoteHangup(intent: Intent) { if (callManager.callId != getCallId(intent)) { Log.e(TAG, "Hangup for non-active call...") + lifecycleScope.launchWhenCreated { + stopForeground(true) + } return } @@ -555,7 +625,11 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { } val callId = getCallId(intent) val description = intent.getStringExtra(EXTRA_REMOTE_DESCRIPTION) - callManager.handleResponseMessage(recipient, callId, SessionDescription(SessionDescription.Type.ANSWER, description)) + callManager.handleResponseMessage( + recipient, + callId, + SessionDescription(SessionDescription.Type.ANSWER, description) + ) } catch (e: PeerConnectionException) { terminate() } @@ -567,14 +641,14 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { val sdpLineIndexes = intent.getIntArrayExtra(EXTRA_ICE_SDP_LINE_INDEX) ?: return val sdps = intent.getStringArrayExtra(EXTRA_ICE_SDP) ?: return if (sdpMids.size != sdpLineIndexes.size || sdpLineIndexes.size != sdps.size) { - Log.w(TAG,"sdp info not of equal length") + Log.w(TAG, "sdp info not of equal length") return } val iceCandidates = sdpMids.indices.map { index -> IceCandidate( - sdpMids[index], - sdpLineIndexes[index], - sdps[index] + sdpMids[index], + sdpLineIndexes[index], + sdps[index] ) } callManager.handleRemoteIceCandidate(iceCandidates, callId) @@ -597,14 +671,17 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { private fun handleIsInCallQuery(intent: Intent) { val listener = intent.getParcelableExtra<ResultReceiver>(EXTRA_RESULT_RECEIVER) ?: return val currentState = callManager.currentConnectionState - val isInCall = if (currentState in arrayOf(*CallState.PENDING_CONNECTION_STATES, CallState.Connected)) 1 else 0 + val isInCall = if (currentState in arrayOf( + *CallState.PENDING_CONNECTION_STATES, + CallState.Connected + ) + ) 1 else 0 listener.send(isInCall, bundleOf()) } private fun registerPowerButtonReceiver() { if (powerButtonReceiver == null) { powerButtonReceiver = PowerButtonReceiver() - registerReceiver(powerButtonReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF)) } } @@ -616,37 +693,56 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { if (callId == getCallId(intent) && isNetworkAvailable && numTimeouts <= MAX_RECONNECTS) { Log.i("Loki", "Trying to re-connect") callManager.networkReestablished() - scheduledTimeout = timeoutExecutor.schedule(TimeoutRunnable(callId, this), TIMEOUT_SECONDS, TimeUnit.SECONDS) + scheduledTimeout = timeoutExecutor.schedule( + TimeoutRunnable(callId, this), + TIMEOUT_SECONDS, + TimeUnit.SECONDS + ) } else if (numTimeouts < MAX_RECONNECTS) { - Log.i("Loki", "Network isn't available, timeouts == $numTimeouts out of $MAX_RECONNECTS") - scheduledReconnect = timeoutExecutor.schedule(CheckReconnectedRunnable(callId, this), RECONNECT_SECONDS, TimeUnit.SECONDS) + Log.i( + "Loki", + "Network isn't available, timeouts == $numTimeouts out of $MAX_RECONNECTS" + ) + scheduledReconnect = timeoutExecutor.schedule( + CheckReconnectedRunnable(callId, this), + RECONNECT_SECONDS, + TimeUnit.SECONDS + ) } else { Log.i("Loki", "Network isn't available, timing out") handleLocalHangup(intent) } } - - private fun handleCheckTimeout(intent: Intent) { val callId = callManager.callId ?: return val callState = callManager.currentConnectionState - if (callId == getCallId(intent) && (callState !in arrayOf(CallState.Connected, CallState.Connecting))) { + if (callId == getCallId(intent) && (callState !in arrayOf( + CallState.Connected, + CallState.Connecting + )) + ) { Log.w(TAG, "Timing out call: $callId") handleLocalHangup(intent) } } private fun setCallInProgressNotification(type: Int, recipient: Recipient?) { - startForeground( + try { + startForeground( CallNotificationBuilder.WEBRTC_NOTIFICATION, CallNotificationBuilder.getCallInProgressNotification(this, type, recipient) - ) + ) + } + catch(e: ForegroundServiceStartNotAllowedException) { + Log.e(TAG, "Failed to setCallInProgressNotification as a foreground service for type: ${type}, trying to update instead") + } + 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) } @@ -661,14 +757,14 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { private fun getRemoteRecipient(intent: Intent): Recipient { val remoteAddress = intent.getParcelableExtra<Address>(EXTRA_RECIPIENT_ADDRESS) - ?: throw AssertionError("No recipient in intent!") + ?: throw AssertionError("No recipient in intent!") return Recipient.from(this, remoteAddress, true) } - private fun getCallId(intent: Intent) : UUID { + private fun getCallId(intent: Intent): UUID { return intent.getSerializableExtra(EXTRA_CALL_ID) as? UUID - ?: throw AssertionError("No callId in intent!") + ?: throw AssertionError("No callId in intent!") } private fun insertMissedCall(recipient: Recipient, signal: Boolean) { @@ -680,24 +776,42 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { } private fun isIncomingMessageExpired(intent: Intent) = - System.currentTimeMillis() - intent.getLongExtra(EXTRA_TIMESTAMP, -1) > TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS) + System.currentTimeMillis() - intent.getLongExtra( + EXTRA_TIMESTAMP, + -1 + ) > TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS) override fun onDestroy() { - Log.d(TAG,"onDestroy()") + Log.d(TAG, "onDestroy()") callManager.unregisterListener(this) callReceiver?.let { receiver -> unregisterReceiver(receiver) } + wiredHeadsetStateReceiver?.let { unregisterReceiver(it) } + powerButtonReceiver?.let { unregisterReceiver(it) } networkChangedReceiver?.unregister(this) wantsToAnswerReceiver?.let { receiver -> LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) } + callManager.shutDownAudioManager() + powerButtonReceiver = null + wiredHeadsetStateReceiver = null networkChangedReceiver = null callReceiver = null uncaughtExceptionHandlerManager?.unregister() wantsToAnswer = false currentTimeouts = 0 isNetworkAvailable = false + if (checkSelfPermission(android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) { + val telephonyManager = getSystemService(TelephonyManager::class.java) + with(telephonyManager) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + this.listen(hangupOnCallAnswered, LISTEN_NONE) + } else { + this.unregisterTelephonyCallback(hangupTelephonyCallback) + } + } + } super.onDestroy() } @@ -709,7 +823,8 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { } } - private class CheckReconnectedRunnable(private val callId: UUID, private val context: Context): Runnable { + private class CheckReconnectedRunnable(private val callId: UUID, private val context: Context) : + Runnable { override fun run() { val intent = Intent(context, WebRtcCallService::class.java) .setAction(ACTION_CHECK_RECONNECT) @@ -718,7 +833,8 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { } } - private class ReconnectTimeoutRunnable(private val callId: UUID, private val context: Context): Runnable { + private class ReconnectTimeoutRunnable(private val callId: UUID, private val context: Context) : + Runnable { override fun run() { val intent = Intent(context, WebRtcCallService::class.java) .setAction(ACTION_CHECK_RECONNECT_TIMEOUT) @@ -727,26 +843,29 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { } } - private class TimeoutRunnable(private val callId: UUID, private val context: Context): Runnable { + private class TimeoutRunnable(private val callId: UUID, private val context: Context) : + Runnable { override fun run() { val intent = Intent(context, WebRtcCallService::class.java) - .setAction(ACTION_CHECK_TIMEOUT) - .putExtra(EXTRA_CALL_ID, callId) + .setAction(ACTION_CHECK_TIMEOUT) + .putExtra(EXTRA_CALL_ID, callId) context.startService(intent) } } private abstract class FailureListener<V>( - expectedState: CallState, - expectedCallId: UUID?, - getState: () -> Pair<CallState, UUID?>): StateAwareListener<V>(expectedState, expectedCallId, getState) { + expectedState: CallState, + expectedCallId: UUID?, + getState: () -> Pair<CallState, UUID?> + ) : StateAwareListener<V>(expectedState, expectedCallId, getState) { override fun onSuccessContinue(result: V) {} } private abstract class SuccessOnlyListener<V>( - expectedState: CallState, - expectedCallId: UUID?, - getState: () -> Pair<CallState, UUID>): StateAwareListener<V>(expectedState, expectedCallId, getState) { + expectedState: CallState, + expectedCallId: UUID?, + getState: () -> Pair<CallState, UUID> + ) : StateAwareListener<V>(expectedState, expectedCallId, getState) { override fun onFailureContinue(throwable: Throwable?) { Log.e(TAG, throwable) throw AssertionError(throwable) @@ -754,9 +873,10 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { } private abstract class StateAwareListener<V>( - private val expectedState: CallState, - private val expectedCallId: UUID?, - private val getState: ()->Pair<CallState, UUID?>): FutureTaskListener<V> { + private val expectedState: CallState, + private val expectedCallId: UUID?, + private val getState: () -> Pair<CallState, UUID?> + ) : FutureTaskListener<V> { companion object { private val TAG = Log.tag(StateAwareListener::class.java) @@ -764,7 +884,7 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { override fun onSuccess(result: V) { if (!isConsistentState()) { - Log.w(TAG,"State has changed since request, aborting success callback...") + Log.w(TAG, "State has changed since request, aborting success callback...") } else { onSuccessContinue(result) } @@ -773,7 +893,7 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { override fun onFailure(exception: ExecutionException?) { if (!isConsistentState()) { Log.w(TAG, exception) - Log.w(TAG,"State has changed since request, aborting failure callback...") + Log.w(TAG, "State has changed since request, aborting failure callback...") } else { exception?.let { onFailureContinue(it.cause) @@ -792,10 +912,10 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { } private fun isConsistentState( - expectedState: CallState, - expectedCallId: UUID?, - currentState: CallState, - currentCallId: UUID? + expectedState: CallState, + expectedCallId: UUID?, + currentState: CallState, + currentCallId: UUID? ): Boolean { return expectedState == currentState && expectedCallId == currentCallId } @@ -817,17 +937,29 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { val intent = Intent(this, WebRtcCallService::class.java) .setAction(ACTION_ICE_CONNECTED) startService(intent) - } else if (newState in arrayOf(FAILED, DISCONNECTED) && (scheduledReconnect == null && scheduledTimeout == null)) { + } else if (newState in arrayOf( + FAILED, + DISCONNECTED + ) && (scheduledReconnect == null && scheduledTimeout == null) + ) { callManager.callId?.let { callId -> callManager.postConnectionEvent(Event.IceDisconnect) { callManager.postViewModelState(CallViewModel.State.CALL_RECONNECTING) if (callManager.isInitiator()) { Log.i("Loki", "Starting reconnect timer") - scheduledReconnect = timeoutExecutor.schedule(CheckReconnectedRunnable(callId, this), RECONNECT_SECONDS, TimeUnit.SECONDS) + scheduledReconnect = timeoutExecutor.schedule( + CheckReconnectedRunnable(callId, this), + RECONNECT_SECONDS, + TimeUnit.SECONDS + ) } else { Log.i("Loki", "Starting timeout, awaiting new reconnect") callManager.postConnectionEvent(Event.PrepareForNewOffer) { - scheduledTimeout = timeoutExecutor.schedule(TimeoutRunnable(callId, this), TIMEOUT_SECONDS, TimeUnit.SECONDS) + scheduledTimeout = timeoutExecutor.schedule( + TimeoutRunnable(callId, this), + TIMEOUT_SECONDS, + TimeUnit.SECONDS + ) } } } @@ -855,7 +987,7 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { override fun onDataChannel(p0: DataChannel?) {} override fun onRenegotiationNeeded() { - Log.w(TAG,"onRenegotiationNeeded was called!") + Log.w(TAG, "onRenegotiationNeeded was called!") } override fun onAddTrack(p0: RtpReceiver?, p1: Array<out MediaStream>?) {} 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/sskenvironment/ProfileManager.kt b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt index c46f75bff8..8b1975865d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/ProfileManager.kt @@ -1,16 +1,23 @@ package org.thoughtcrime.securesms.sskenvironment import android.content.Context +import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.jobs.RetrieveProfileAvatarJob +import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient -import org.thoughtcrime.securesms.ApplicationContext +import org.session.libsignal.utilities.IdPrefix +import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities -class ProfileManager : SSKEnvironment.ProfileManagerProtocol { +class ProfileManager(private val context: Context, private val configFactory: ConfigFactory) : SSKEnvironment.ProfileManagerProtocol { override fun setNickname(context: Context, recipient: Recipient, nickname: String?) { + if (recipient.isLocalNumber) return val sessionID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() var contact = contactDatabase.getContactWithSessionID(sessionID) @@ -20,10 +27,12 @@ class ProfileManager : SSKEnvironment.ProfileManagerProtocol { contact.nickname = nickname contactDatabase.setContact(contact) } + contactUpdatedInternal(contact) } - override fun setName(context: Context, recipient: Recipient, name: String) { + override fun setName(context: Context, recipient: Recipient, name: String?) { // New API + if (recipient.isLocalNumber) return val sessionID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() var contact = contactDatabase.getContactWithSessionID(sessionID) @@ -37,41 +46,69 @@ class ProfileManager : SSKEnvironment.ProfileManagerProtocol { val database = DatabaseComponent.get(context).recipientDatabase() database.setProfileName(recipient, name) recipient.notifyListeners() + contactUpdatedInternal(contact) } - override fun setProfilePictureURL(context: Context, recipient: Recipient, profilePictureURL: String) { - val job = RetrieveProfileAvatarJob(recipient, profilePictureURL) - val jobManager = ApplicationContext.getInstance(context).jobManager - jobManager.add(job) + override fun setProfilePicture( + context: Context, + recipient: Recipient, + profilePictureURL: String?, + profileKey: ByteArray? + ) { + val hasPendingDownload = DatabaseComponent + .get(context) + .sessionJobDatabase() + .getAllJobs(RetrieveProfileAvatarJob.KEY).any { + (it.value as? RetrieveProfileAvatarJob)?.recipientAddress == recipient.address + } + val resolved = recipient.resolve() + DatabaseComponent.get(context).storage().setProfilePicture( + recipient = resolved, + newProfileKey = profileKey, + newProfilePicture = profilePictureURL + ) val sessionID = recipient.address.serialize() val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() var contact = contactDatabase.getContactWithSessionID(sessionID) if (contact == null) contact = Contact(sessionID) contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) - if (contact.profilePictureURL != profilePictureURL) { + if (!contact.profilePictureEncryptionKey.contentEquals(profileKey) || contact.profilePictureURL != profilePictureURL) { + contact.profilePictureEncryptionKey = profileKey contact.profilePictureURL = profilePictureURL contactDatabase.setContact(contact) } - } - - override fun setProfileKey(context: Context, recipient: Recipient, profileKey: ByteArray) { - // New API - val sessionID = recipient.address.serialize() - val contactDatabase = DatabaseComponent.get(context).sessionContactDatabase() - var contact = contactDatabase.getContactWithSessionID(sessionID) - if (contact == null) contact = Contact(sessionID) - contact.threadID = DatabaseComponent.get(context).storage().getThreadId(recipient.address) - if (!contact.profilePictureEncryptionKey.contentEquals(profileKey)) { - contact.profilePictureEncryptionKey = profileKey - contactDatabase.setContact(contact) + contactUpdatedInternal(contact) + if (!hasPendingDownload) { + val job = RetrieveProfileAvatarJob(profilePictureURL, recipient.address) + JobQueue.shared.add(job) } - // Old API - val database = DatabaseComponent.get(context).recipientDatabase() - database.setProfileKey(recipient, profileKey) } override fun setUnidentifiedAccessMode(context: Context, recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode) { val database = DatabaseComponent.get(context).recipientDatabase() database.setUnidentifiedAccessMode(recipient, unidentifiedAccessMode) } + + override fun contactUpdatedInternal(contact: Contact): String? { + val contactConfig = configFactory.contacts ?: return null + if (contact.sessionID == TextSecurePreferences.getLocalNumber(context)) return null + val sessionId = SessionId(contact.sessionID) + if (sessionId.prefix != IdPrefix.STANDARD) return null // only internally store standard session IDs + contactConfig.upsertContact(contact.sessionID) { + this.name = contact.name.orEmpty() + this.nickname = contact.nickname.orEmpty() + val url = contact.profilePictureURL + val key = contact.profilePictureEncryptionKey + if (!url.isNullOrEmpty() && key != null && key.size == 32) { + this.profilePicture = UserPic(url, key) + } else if (url.isNullOrEmpty() && key == null) { + this.profilePicture = UserPic.DEFAULT + } + } + if (contactConfig.needsPush()) { + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + } + return contactConfig.get(contact.sessionID)?.hashCode()?.toString() + } + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/TypingStatusRepository.java b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/TypingStatusRepository.java index c1d6e53690..a18ad8211f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/TypingStatusRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sskenvironment/TypingStatusRepository.java @@ -1,20 +1,21 @@ package org.thoughtcrime.securesms.sskenvironment; import android.annotation.SuppressLint; +import android.content.Context; + +import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; -import android.content.Context; -import androidx.annotation.NonNull; import com.annimon.stream.Collectors; import com.annimon.stream.Stream; import org.jetbrains.annotations.NotNull; import org.session.libsession.utilities.Address; -import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.SSKEnvironment; import org.session.libsession.utilities.TextSecurePreferences; import org.session.libsession.utilities.Util; +import org.session.libsession.utilities.recipients.Recipient; import org.session.libsignal.utilities.Log; import java.util.ArrayList; @@ -198,12 +199,12 @@ public class TypingStatusRepository implements SSKEnvironment.TypingIndicatorsPr if (device != typist.device) return false; if (threadId != typist.threadId) return false; - return author.equals(typist.author); + return author.getAddress().equals(typist.author.getAddress()); } @Override public int hashCode() { - int result = author.hashCode(); + int result = author.getAddress().hashCode(); result = 31 * result + device; result = 31 * result + (int) (threadId ^ (threadId >>> 32)); return result; 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/Colors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Colors.kt new file mode 100644 index 0000000000..55bc1be62e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Colors.kt @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.ui + +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Colors +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +val colorDestructive = Color(0xffFF453A) + +const val classicDark0 = 0xff111111 +const val classicDark1 = 0xff1B1B1B +const val classicDark2 = 0xff2D2D2D +const val classicDark3 = 0xff414141 +const val classicDark4 = 0xff767676 +const val classicDark5 = 0xffA1A2A1 +const val classicDark6 = 0xffFFFFFF + +const val classicLight0 = 0xff000000 +const val classicLight1 = 0xff6D6D6D +const val classicLight2 = 0xffA1A2A1 +const val classicLight3 = 0xffDFDFDF +const val classicLight4 = 0xffF0F0F0 +const val classicLight5 = 0xffF9F9F9 +const val classicLight6 = 0xffFFFFFF + +const val oceanDark0 = 0xff000000 +const val oceanDark1 = 0xff1A1C28 +const val oceanDark2 = 0xff252735 +const val oceanDark3 = 0xff2B2D40 +const val oceanDark4 = 0xff3D4A5D +const val oceanDark5 = 0xffA6A9CE +const val oceanDark6 = 0xff5CAACC +const val oceanDark7 = 0xffFFFFFF + +const val oceanLight0 = 0xff000000 +const val oceanLight1 = 0xff19345D +const val oceanLight2 = 0xff6A6E90 +const val oceanLight3 = 0xff5CAACC +const val oceanLight4 = 0xffB3EDF2 +const val oceanLight5 = 0xffE7F3F4 +const val oceanLight6 = 0xffECFAFB +const val oceanLight7 = 0xffFCFFFF + +val ocean_accent = Color(0xff57C9FA) + +val oceanLights = arrayOf(oceanLight0, oceanLight1, oceanLight2, oceanLight3, oceanLight4, oceanLight5, oceanLight6, oceanLight7) +val oceanDarks = arrayOf(oceanDark0, oceanDark1, oceanDark2, oceanDark3, oceanDark4, oceanDark5, oceanDark6, oceanDark7) +val classicLights = arrayOf(classicLight0, classicLight1, classicLight2, classicLight3, classicLight4, classicLight5, classicLight6) +val classicDarks = arrayOf(classicDark0, classicDark1, classicDark2, classicDark3, classicDark4, classicDark5, classicDark6) + +val oceanLightColors = oceanLights.map(::Color) +val oceanDarkColors = oceanDarks.map(::Color) +val classicLightColors = classicLights.map(::Color) +val classicDarkColors = classicDarks.map(::Color) + +val blackAlpha40 = Color.Black.copy(alpha = 0.4f) + +@Composable +fun transparentButtonColors() = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent) + +@Composable +fun destructiveButtonColors() = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent, contentColor = colorDestructive) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt new file mode 100644 index 0000000000..6c223a45f2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -0,0 +1,276 @@ +package org.thoughtcrime.securesms.ui + +import androidx.annotation.DrawableRes +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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.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.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.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 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( + text: String, + @DrawableRes icon: Int, + colors: ButtonColors = transparentButtonColors(), + contentDescription: String = text, + onClick: () -> Unit +) { + TextButton( + modifier = Modifier + .fillMaxWidth() + .height(60.dp), + colors = colors, + onClick = onClick, + shape = RectangleShape, + ) { + Box(modifier = Modifier + .width(80.dp) + .fillMaxHeight()) { + Icon( + painter = painterResource(id = icon), + contentDescription = contentDescription, + modifier = Modifier.align(Alignment.Center) + ) + } + Text(text, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +fun Cell(content: @Composable () -> Unit) { + CellWithPaddingAndMargin(padding = 0.dp) { content() } +} +@Composable +fun CellNoMargin(content: @Composable () -> Unit) { + CellWithPaddingAndMargin(padding = 0.dp, margin = 0.dp) { content() } +} + +@Composable +fun CellWithPaddingAndMargin( + padding: Dp = 24.dp, + margin: Dp = 32.dp, + content: @Composable () -> Unit +) { + Card( + backgroundColor = MaterialTheme.colors.cellColor, + shape = RoundedCornerShape(16.dp), + elevation = 0.dp, + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(horizontal = margin), + ) { + Box(Modifier.padding(padding)) { content() } + } +} + +@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 + +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() + + 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 + ) + + 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() { + androidx.compose.material.Divider( + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +fun RowScope.Avatar(recipient: Recipient) { + Box( + modifier = Modifier + .width(60.dp) + .align(Alignment.CenterVertically) + ) { + AndroidView( + factory = { + ProfilePictureView(it).apply { update(recipient) } + }, + modifier = Modifier + .width(46.dp) + .height(46.dp) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt new file mode 100644 index 0000000000..e472209005 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt @@ -0,0 +1,61 @@ +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) + + +/** + * Represents some text with an associated title. + */ +data class TitledText(val title: GetString, val text: String) { + constructor(title: String, text: String): this(GetString(title), text) + constructor(@StringRes title: Int, text: String): this(GetString(title), text) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt new file mode 100644 index 0000000000..3fa861fb71 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Themes.kt @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.ui + +import android.content.Context +import androidx.annotation.AttrRes +import androidx.appcompat.view.ContextThemeWrapper +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.google.accompanist.themeadapter.appcompat.AppCompatTheme +import com.google.android.material.color.MaterialColors +import network.loki.messenger.R + +val LocalExtraColors = staticCompositionLocalOf<ExtraColors> { error("No Custom Attribute value provided") } + + +data class ExtraColors( + val settingsBackground: Color, + val prominentButtonColor: Color +) + +/** + * Converts current Theme to Compose Theme. + */ +@Composable +fun AppTheme( + content: @Composable () -> Unit +) { + val extraColors = LocalContext.current.run { + ExtraColors( + settingsBackground = getColorFromTheme(R.attr.colorSettingsBackground), + prominentButtonColor = getColorFromTheme(R.attr.prominentButtonColor), + ) + } + + CompositionLocalProvider(LocalExtraColors provides extraColors) { + AppCompatTheme { + content() + } + } +} + +fun Context.getColorFromTheme(@AttrRes attr: Int, defaultValue: Int = 0x0): Color = + MaterialColors.getColor(this, attr, defaultValue).let(::Color) + +/** + * Set the theme and a background for Compose Previews. + */ +@Composable +fun PreviewTheme( + themeResId: Int, + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalContext provides ContextThemeWrapper(LocalContext.current, themeResId) + ) { + AppTheme { + Box(modifier = Modifier.background(color = MaterialTheme.colors.background)) { + content() + } + } + } +} + +class ThemeResPreviewParameterProvider : PreviewParameterProvider<Int> { + override val values = sequenceOf( + R.style.Classic_Dark, + R.style.Classic_Light, + R.style.Ocean_Dark, + R.style.Ocean_Light, + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt index d5b361ecd6..5ff823a15c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ActivityUtilities.kt @@ -7,10 +7,10 @@ import android.view.View import androidx.annotation.StyleRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.DialogFragment import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.BaseActionBarActivity -import org.thoughtcrime.securesms.conversation.v2.utilities.BaseDialog fun BaseActionBarActivity.setUpActionBarSessionLogo(hideBackButton: Boolean = false) { val actionbar = supportActionBar!! @@ -66,7 +66,7 @@ interface ActivityDispatcher { fun get(context: Context) = context.getSystemService(SERVICE) as? ActivityDispatcher } fun dispatchIntent(body: (Context)->Intent?) - fun showDialog(baseDialog: BaseDialog, tag: String? = null) + fun showDialog(dialogFragment: DialogFragment, tag: String? = null) } fun TextSecurePreferences.themeState(): ThemeState { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.kt deleted file mode 100644 index eaaf06f456..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.kt +++ /dev/null @@ -1,352 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.DocumentsContract -import android.widget.Toast -import androidx.annotation.WorkerThread -import androidx.documentfile.provider.DocumentFile -import androidx.fragment.app.Fragment -import network.loki.messenger.R -import org.greenrobot.eventbus.EventBus -import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.ByteUtil -import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.backup.BackupEvent -import org.thoughtcrime.securesms.backup.BackupPassphrase -import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference -import org.thoughtcrime.securesms.backup.FullBackupExporter -import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider -import org.thoughtcrime.securesms.crypto.IdentityKeyUtil -import org.thoughtcrime.securesms.database.BackupFileRecord -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.service.LocalBackupListener -import java.io.IOException -import java.security.MessageDigest -import java.security.NoSuchAlgorithmException -import java.security.SecureRandom -import java.text.SimpleDateFormat -import java.util.* - -object BackupUtil { - private const val MASTER_SECRET_UTIL_PREFERENCES_NAME = "SecureSMS-Preferences" - private const val TAG = "BackupUtil" - const val BACKUP_FILE_MIME_TYPE = "application/session-backup" - const val BACKUP_PASSPHRASE_LENGTH = 30 - - fun getBackupRecords(context: Context): List<SharedPreference> { - val prefName = MASTER_SECRET_UTIL_PREFERENCES_NAME - val preferences = context.getSharedPreferences(prefName, 0) - val prefList = LinkedList<SharedPreference>() - prefList.add(SharedPreference.newBuilder() - .setFile(prefName) - .setKey(IdentityKeyUtil.IDENTITY_PUBLIC_KEY_PREF) - .setValue(preferences.getString(IdentityKeyUtil.IDENTITY_PUBLIC_KEY_PREF, null)) - .build()) - prefList.add(SharedPreference.newBuilder() - .setFile(prefName) - .setKey(IdentityKeyUtil.IDENTITY_PRIVATE_KEY_PREF) - .setValue(preferences.getString(IdentityKeyUtil.IDENTITY_PRIVATE_KEY_PREF, null)) - .build()) - if (preferences.contains(IdentityKeyUtil.ED25519_PUBLIC_KEY)) { - prefList.add(SharedPreference.newBuilder() - .setFile(prefName) - .setKey(IdentityKeyUtil.ED25519_PUBLIC_KEY) - .setValue(preferences.getString(IdentityKeyUtil.ED25519_PUBLIC_KEY, null)) - .build()) - } - if (preferences.contains(IdentityKeyUtil.ED25519_SECRET_KEY)) { - prefList.add(SharedPreference.newBuilder() - .setFile(prefName) - .setKey(IdentityKeyUtil.ED25519_SECRET_KEY) - .setValue(preferences.getString(IdentityKeyUtil.ED25519_SECRET_KEY, null)) - .build()) - } - prefList.add(SharedPreference.newBuilder() - .setFile(prefName) - .setKey(IdentityKeyUtil.LOKI_SEED) - .setValue(preferences.getString(IdentityKeyUtil.LOKI_SEED, null)) - .build()) - return prefList - } - - /** - * Set app-wide configuration to enable the backups and schedule them. - * - * Make sure that the backup dir is selected prior activating the backup. - * Use [BackupDirSelector] or [setBackupDirUri] manually. - */ - @JvmStatic - @Throws(IOException::class) - fun enableBackups(context: Context, password: String) { - val backupDir = getBackupDirUri(context) - if (backupDir == null || !validateDirAccess(context, backupDir)) { - throw IOException("Backup dir is not set or invalid.") - } - - BackupPassphrase.set(context, password) - TextSecurePreferences.setBackupEnabled(context, true) - LocalBackupListener.schedule(context) - } - - /** - * Set app-wide configuration to disable the backups. - * - * This call resets the backup dir value. - * Make sure to call [setBackupDirUri] prior next call to [enableBackups]. - * - * @param deleteBackupFiles if true, deletes all the previously created backup files - * (if the app has access to them) - */ - @JvmStatic - fun disableBackups(context: Context, deleteBackupFiles: Boolean) { - BackupPassphrase.set(context, null) - TextSecurePreferences.setBackupEnabled(context, false) - if (deleteBackupFiles) { - deleteAllBackupFiles(context) - } - setBackupDirUri(context, null) - } - - @JvmStatic - fun getLastBackupTimeString(context: Context, locale: Locale): String { - val timestamp = DatabaseComponent.get(context).lokiBackupFilesDatabase().getLastBackupFileTime() - if (timestamp == null) { - return context.getString(R.string.BackupUtil_never) - } - return DateUtils.getDisplayFormattedTimeSpanString(context, locale, timestamp.time) - } - - @JvmStatic - fun getLastBackup(context: Context): BackupFileRecord? { - return DatabaseComponent.get(context).lokiBackupFilesDatabase().getLastBackupFile() - } - - @JvmStatic - fun generateBackupPassphrase(): Array<String> { - val random = ByteArray(BACKUP_PASSPHRASE_LENGTH).also { SecureRandom().nextBytes(it) } - return Array(6) { i -> - String.format("%05d", ByteUtil.byteArray5ToLong(random, i * 5) % 100000) - } - } - - @JvmStatic - fun validateDirAccess(context: Context, dirUri: Uri): Boolean { - val hasWritePermission = context.contentResolver.persistedUriPermissions.any { - it.isWritePermission && it.uri == dirUri - } - if (!hasWritePermission) return false - - val document = DocumentFile.fromTreeUri(context, dirUri) - if (document == null || !document.exists()) { - return false - } - - return true - } - - @JvmStatic - fun getBackupDirUri(context: Context): Uri? { - val dirUriString = TextSecurePreferences.getBackupSaveDir(context) ?: return null - return Uri.parse(dirUriString) - } - - @JvmStatic - fun setBackupDirUri(context: Context, uriString: String?) { - TextSecurePreferences.setBackupSaveDir(context, uriString) - } - - /** - * @return The selected backup directory if it's valid (exists, is writable). - */ - @JvmStatic - fun getSelectedBackupDirIfValid(context: Context): Uri? { - val dirUri = getBackupDirUri(context) - - if (dirUri == null) { - Log.v(TAG, "The backup dir wasn't selected yet.") - return null - } - if (!validateDirAccess(context, dirUri)) { - Log.v(TAG, "Cannot validate the access to the dir $dirUri.") - return null - } - - return dirUri; - } - - @JvmStatic - @WorkerThread - @Throws(IOException::class) - fun createBackupFile(context: Context): BackupFileRecord { - val backupPassword = BackupPassphrase.get(context) - ?: throw IOException("Backup password is null") - - val dirUri = getSelectedBackupDirIfValid(context) - ?: throw IOException("Backup save directory is not selected or invalid") - - val date = Date() - val timestamp = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(date) - val fileName = String.format("session-%s.backup", timestamp) - - val fileUri = DocumentsContract.createDocument( - context.contentResolver, - DocumentFile.fromTreeUri(context, dirUri)!!.uri, - BACKUP_FILE_MIME_TYPE, - fileName) - - if (fileUri == null) { - Toast.makeText(context, "Cannot create writable file in the dir $dirUri", Toast.LENGTH_LONG).show() - throw IOException("Cannot create writable file in the dir $dirUri") - } - - try { - FullBackupExporter.export(context, - AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret, - DatabaseComponent.get(context).openHelper().readableDatabase, - fileUri, - backupPassword) - } catch (e: Exception) { - // Delete the backup file on any error. - DocumentsContract.deleteDocument(context.contentResolver, fileUri) - throw e - } - - //TODO Use real file size. - val record = DatabaseComponent.get(context).lokiBackupFilesDatabase() - .insertBackupFile(BackupFileRecord(fileUri, -1, date)) - - Log.v(TAG, "A backup file was created: $fileUri") - - return record - } - - @JvmStatic - @JvmOverloads - fun deleteAllBackupFiles(context: Context, except: Collection<BackupFileRecord>? = null) { - val db = DatabaseComponent.get(context).lokiBackupFilesDatabase() - db.getBackupFiles().iterator().forEach { record -> - if (except != null && except.contains(record)) return@forEach - - // Try to delete the related file. The operation may fail in many cases - // (the user moved/deleted the file, revoked the write permission, etc), so that's OK. - try { - val result = DocumentsContract.deleteDocument(context.contentResolver, record.uri) - if (!result) { - Log.w(TAG, "Failed to delete backup file: ${record.uri}") - } - } catch (e: Exception) { - Log.w(TAG, "Failed to delete backup file: ${record.uri}", e) - } - - db.deleteBackupFile(record) - - Log.v(TAG, "Backup file was deleted: ${record.uri}") - } - } - - @JvmStatic - fun computeBackupKey(passphrase: String, salt: ByteArray?): ByteArray { - return try { - EventBus.getDefault().post(BackupEvent.createProgress(0)) - val digest = MessageDigest.getInstance("SHA-512") - val input = passphrase.replace(" ", "").toByteArray() - var hash: ByteArray = input - if (salt != null) digest.update(salt) - for (i in 0..249999) { - if (i % 1000 == 0) EventBus.getDefault().post(BackupEvent.createProgress(0)) - digest.update(hash) - hash = digest.digest(input) - } - ByteUtil.trim(hash, 32) - } catch (e: NoSuchAlgorithmException) { - throw AssertionError(e) - } - } -} - -/** - * An utility class to help perform backup directory selection requests. - * - * An instance of this class should be created per an [Activity] or [Fragment] - * and [onActivityResult] should be called appropriately. - */ -class BackupDirSelector(private val contextProvider: ContextProvider) { - - companion object { - private const val REQUEST_CODE_SAVE_DIR = 7844 - } - - private val context: Context get() = contextProvider.getContext() - - private var listener: Listener? = null - - constructor(activity: Activity) : - this(ActivityContextProvider(activity)) - - constructor(fragment: Fragment) : - this(FragmentContextProvider(fragment)) - - /** - * Performs ACTION_OPEN_DOCUMENT_TREE intent to select backup directory URI. - * If the directory is already selected and valid, the request will be skipped. - * @param force if true, the previous selection is ignored and the user is requested to select another directory. - * @param onSelectedListener an optional action to perform once the directory is selected. - */ - fun selectBackupDir(force: Boolean, onSelectedListener: Listener? = null) { - if (!force) { - val dirUri = BackupUtil.getSelectedBackupDirIfValid(context) - if (dirUri != null && onSelectedListener != null) { - onSelectedListener.onBackupDirSelected(dirUri) - } - return - } - - // Let user pick the dir. - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - - // Request read/write permission grant for the dir. - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) - - // Set the default dir. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val dirUri = BackupUtil.getBackupDirUri(context) - intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, dirUri - ?: Uri.fromFile(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS))) - } - - if (onSelectedListener != null) { - this.listener = onSelectedListener - } - - contextProvider.startActivityForResult(intent, REQUEST_CODE_SAVE_DIR) - } - - fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode != REQUEST_CODE_SAVE_DIR) return - - if (resultCode == Activity.RESULT_OK && data != null && data.data != null) { - // Acquire persistent access permissions for the file selected. - val persistentFlags: Int = data.flags and - (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - context.contentResolver.takePersistableUriPermission(data.data!!, persistentFlags) - - BackupUtil.setBackupDirUri(context, data.dataString) - - listener?.onBackupDirSelected(data.data!!) - } - - listener = null - } - - @FunctionalInterface - interface Listener { - fun onBackupDirSelected(uri: Uri) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt index 56c0a55dda..0ba63fc549 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CallNotificationBuilder.kt @@ -1,12 +1,10 @@ package org.thoughtcrime.securesms.util import android.app.Notification -import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK -import android.os.Build import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.app.NotificationCompat @@ -32,15 +30,7 @@ class CallNotificationBuilder { @JvmStatic fun areNotificationsEnabled(context: Context): Boolean { val notificationManager = NotificationManagerCompat.from(context) - return when { - !notificationManager.areNotificationsEnabled() -> false - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { - notificationManager.notificationChannels.firstOrNull { channel -> - channel.importance == NotificationManager.IMPORTANCE_NONE - } == null - } - else -> true - } + return notificationManager.areNotificationsEnabled() } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt index fd462417d9..9d10cfdab5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationMessageUtilities.kt @@ -1,18 +1,66 @@ package org.thoughtcrime.securesms.util import android.content.Context +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.Contacts +import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.UserGroupsConfig +import network.loki.messenger.libsession_util.UserProfile +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.Contact +import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.libsession_util.util.GroupInfo +import network.loki.messenger.libsession_util.util.UserPic import nl.komponents.kovenant.Promise +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.jobs.ConfigurationSyncJob +import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.control.ConfigurationMessage import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.WindowDebouncer +import org.session.libsignal.crypto.ecc.DjbECPublicKey +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.toHexString +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import java.util.Timer object ConfigurationMessageUtilities { + private val debouncer = WindowDebouncer(3000, Timer()) + + private fun scheduleConfigSync(userPublicKey: String) { + debouncer.publish { + // don't schedule job if we already have one + val storage = MessagingModuleConfiguration.shared.storage + val ourDestination = Destination.Contact(userPublicKey) + val currentStorageJob = storage.getConfigSyncJob(ourDestination) + if (currentStorageJob != null) { + (currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true) + return@publish + } + val newConfigSync = ConfigurationSyncJob(ourDestination) + JobQueue.shared.add(newConfigSync) + } + } + @JvmStatic fun syncConfigurationIfNeeded(context: Context) { + // add if check here to schedule new config job process and return early val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return + val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context) + val currentTime = SnodeAPI.nowWithOffset + if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { + scheduleConfigSync(userPublicKey) + return + } val lastSyncTime = TextSecurePreferences.getLastConfigurationSyncTime(context) val now = System.currentTimeMillis() if (now - lastSyncTime < 7 * 24 * 60 * 60 * 1000) return @@ -35,7 +83,16 @@ object ConfigurationMessageUtilities { } fun forceSyncConfigurationNowIfNeeded(context: Context): Promise<Unit, Exception> { - val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofSuccess(Unit) + // add if check here to schedule new config job process and return early + val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return Promise.ofFail(NullPointerException("User Public Key is null")) + val forcedConfig = TextSecurePreferences.hasForcedNewConfig(context) + val currentTime = SnodeAPI.nowWithOffset + if (ConfigBase.isNewConfigEnabled(forcedConfig, currentTime)) { + // schedule job if none exist + // don't schedule job if we already have one + scheduleConfigSync(userPublicKey) + return Promise.ofSuccess(Unit) + } val contacts = ContactUtilities.getAllContacts(context).filter { recipient -> !recipient.isGroupRecipient && !recipient.name.isNullOrEmpty() && !recipient.isLocalNumber && recipient.address.serialize().isNotEmpty() }.map { recipient -> @@ -50,9 +107,179 @@ object ConfigurationMessageUtilities { ) } val configurationMessage = ConfigurationMessage.getCurrent(contacts) ?: return Promise.ofSuccess(Unit) - val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey))) + val promise = MessageSender.send(configurationMessage, Destination.from(Address.fromSerialized(userPublicKey)), isSyncMessage = true) TextSecurePreferences.setLastConfigurationSyncTime(context, System.currentTimeMillis()) return promise } + private fun maybeUserSecretKey() = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes + + fun generateUserProfileConfigDump(): ByteArray? { + val storage = MessagingModuleConfiguration.shared.storage + val ownPublicKey = storage.getUserPublicKey() ?: return null + val config = ConfigurationMessage.getCurrent(listOf()) ?: return null + val secretKey = maybeUserSecretKey() ?: return null + val profile = UserProfile.newInstance(secretKey) + profile.setName(config.displayName) + val picUrl = config.profilePicture + val picKey = config.profileKey + if (!picUrl.isNullOrEmpty() && picKey.isNotEmpty()) { + profile.setPic(UserPic(picUrl, picKey)) + } + val ownThreadId = storage.getThreadId(Address.fromSerialized(ownPublicKey)) + profile.setNtsPriority( + if (ownThreadId != null) + if (storage.isPinned(ownThreadId)) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE + else ConfigBase.PRIORITY_HIDDEN + ) + val dump = profile.dump() + profile.free() + return dump + } + + fun generateContactConfigDump(): ByteArray? { + val secretKey = maybeUserSecretKey() ?: return null + val storage = MessagingModuleConfiguration.shared.storage + val localUserKey = storage.getUserPublicKey() ?: return null + val contactsWithSettings = storage.getAllContacts().filter { recipient -> + recipient.sessionID != localUserKey && recipient.sessionID.startsWith(IdPrefix.STANDARD.value) + && storage.getThreadId(recipient.sessionID) != null + }.map { contact -> + val address = Address.fromSerialized(contact.sessionID) + val thread = storage.getThreadId(address) + val isPinned = if (thread != null) { + storage.isPinned(thread) + } else false + + Triple(contact, storage.getRecipientSettings(address)!!, isPinned) + } + val contactConfig = Contacts.newInstance(secretKey) + for ((contact, settings, isPinned) in contactsWithSettings) { + val url = contact.profilePictureURL + val key = contact.profilePictureEncryptionKey + val userPic = if (url.isNullOrEmpty() || key?.isNotEmpty() != true) { + null + } else { + UserPic(url, key) + } + + val contactInfo = Contact( + id = contact.sessionID, + name = contact.name.orEmpty(), + nickname = contact.nickname.orEmpty(), + blocked = settings.isBlocked, + approved = settings.isApproved, + approvedMe = settings.hasApprovedMe(), + profilePicture = userPic ?: UserPic.DEFAULT, + priority = if (isPinned) 1 else 0, + expiryMode = if (settings.expireMessages == 0) ExpiryMode.NONE else ExpiryMode.AfterRead(settings.expireMessages.toLong()) + ) + contactConfig.set(contactInfo) + } + val dump = contactConfig.dump() + contactConfig.free() + if (dump.isEmpty()) return null + return dump + } + + fun generateConversationVolatileDump(context: Context): ByteArray? { + val secretKey = maybeUserSecretKey() ?: return null + val storage = MessagingModuleConfiguration.shared.storage + val convoConfig = ConversationVolatileConfig.newInstance(secretKey) + val threadDb = DatabaseComponent.get(context).threadDatabase() + threadDb.approvedConversationList.use { cursor -> + val reader = threadDb.readerFor(cursor) + var current = reader.next + while (current != null) { + val recipient = current.recipient + val contact = when { + recipient.isCommunityRecipient -> { + val openGroup = storage.getOpenGroup(current.threadId) ?: continue + val (base, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: continue + convoConfig.getOrConstructCommunity(base, room, pubKey) + } + recipient.isClosedGroupRecipient -> { + val groupPublicKey = GroupUtil.doubleDecodeGroupId(recipient.address.serialize()) + convoConfig.getOrConstructLegacyGroup(groupPublicKey) + } + recipient.isContactRecipient -> { + if (recipient.isLocalNumber) null // this is handled by the user profile NTS data + else if (recipient.isOpenGroupInboxRecipient) null // specifically exclude + else if (!recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) null + else convoConfig.getOrConstructOneToOne(recipient.address.serialize()) + } + else -> null + } + if (contact == null) { + current = reader.next + continue + } + contact.lastRead = current.lastSeen + contact.unread = false + convoConfig.set(contact) + current = reader.next + } + } + + val dump = convoConfig.dump() + convoConfig.free() + if (dump.isEmpty()) return null + return dump + } + + fun generateUserGroupDump(context: Context): ByteArray? { + val secretKey = maybeUserSecretKey() ?: return null + val storage = MessagingModuleConfiguration.shared.storage + val groupConfig = UserGroupsConfig.newInstance(secretKey) + val allOpenGroups = storage.getAllOpenGroups().values.mapNotNull { openGroup -> + val (baseUrl, room, pubKey) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return@mapNotNull null + val pubKeyHex = Hex.toStringCondensed(pubKey) + val baseInfo = BaseCommunityInfo(baseUrl, room, pubKeyHex) + val threadId = storage.getThreadId(openGroup) ?: return@mapNotNull null + val isPinned = storage.isPinned(threadId) + GroupInfo.CommunityGroupInfo(baseInfo, if (isPinned) 1 else 0) + } + + val allLgc = storage.getAllGroups(includeInactive = false).filter { + it.isClosedGroup && it.isActive && it.members.size > 1 + }.mapNotNull { group -> + val groupAddress = Address.fromSerialized(group.encodedId) + val groupPublicKey = GroupUtil.doubleDecodeGroupID(groupAddress.serialize()).toHexString() + val recipient = storage.getRecipientSettings(groupAddress) ?: return@mapNotNull null + val encryptionKeyPair = storage.getLatestClosedGroupEncryptionKeyPair(groupPublicKey) ?: return@mapNotNull null + val threadId = storage.getThreadId(group.encodedId) + val isPinned = threadId?.let { storage.isPinned(threadId) } ?: false + val admins = group.admins.map { it.serialize() to true }.toMap() + val members = group.members.filterNot { it.serialize() !in admins.keys }.map { it.serialize() to false }.toMap() + GroupInfo.LegacyGroupInfo( + sessionId = groupPublicKey, + name = group.title, + members = admins + members, + priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE, + encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte + encSecKey = encryptionKeyPair.privateKey.serialize(), + disappearingTimer = recipient.expireMessages.toLong(), + joinedAt = (group.formationTimestamp / 1000L) + ) + } + (allOpenGroups + allLgc).forEach { groupInfo -> + groupConfig.set(groupInfo) + } + val dump = groupConfig.dump() + groupConfig.free() + if (dump.isEmpty()) return null + return dump + } + + @JvmField + val DELETE_INACTIVE_GROUPS: String = """ + DELETE FROM ${GroupDatabase.TABLE_NAME} WHERE ${GroupDatabase.GROUP_ID} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%'); + DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.ADDRESS} IN (SELECT ${ThreadDatabase.ADDRESS} FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%'); + """.trimIndent() + + @JvmField + val DELETE_INACTIVE_ONE_TO_ONES: String = """ + DELETE FROM ${ThreadDatabase.TABLE_NAME} WHERE ${ThreadDatabase.MESSAGE_COUNT} <= 0 AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.CLOSED_GROUP_PREFIX}%' AND ${ThreadDatabase.ADDRESS} NOT LIKE '${GroupUtil.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/CursorUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/CursorUtil.java index e328e34e0b..bd3d65b9d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CursorUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CursorUtil.java @@ -4,8 +4,6 @@ import android.database.Cursor; import androidx.annotation.NonNull; -import java.util.Optional; - public final class CursorUtil { @@ -19,71 +17,8 @@ public final class CursorUtil { return cursor.getInt(cursor.getColumnIndexOrThrow(column)); } - public static float requireFloat(@NonNull Cursor cursor, @NonNull String column) { - return cursor.getFloat(cursor.getColumnIndexOrThrow(column)); - } - public static long requireLong(@NonNull Cursor cursor, @NonNull String column) { return cursor.getLong(cursor.getColumnIndexOrThrow(column)); } - public static boolean requireBoolean(@NonNull Cursor cursor, @NonNull String column) { - return requireInt(cursor, column) != 0; - } - - public static byte[] requireBlob(@NonNull Cursor cursor, @NonNull String column) { - return cursor.getBlob(cursor.getColumnIndexOrThrow(column)); - } - - public static boolean isNull(@NonNull Cursor cursor, @NonNull String column) { - return cursor.isNull(cursor.getColumnIndexOrThrow(column)); - } - - public static Optional<String> getString(@NonNull Cursor cursor, @NonNull String column) { - if (cursor.getColumnIndex(column) < 0) { - return Optional.empty(); - } else { - return Optional.ofNullable(requireString(cursor, column)); - } - } - - public static Optional<Integer> getInt(@NonNull Cursor cursor, @NonNull String column) { - if (cursor.getColumnIndex(column) < 0) { - return Optional.empty(); - } else { - return Optional.of(requireInt(cursor, column)); - } - } - - public static Optional<Boolean> getBoolean(@NonNull Cursor cursor, @NonNull String column) { - if (cursor.getColumnIndex(column) < 0) { - return Optional.empty(); - } else { - return Optional.of(requireBoolean(cursor, column)); - } - } - - public static Optional<byte[]> getBlob(@NonNull Cursor cursor, @NonNull String column) { - if (cursor.getColumnIndex(column) < 0) { - return Optional.empty(); - } else { - return Optional.ofNullable(requireBlob(cursor, column)); - } - } - - /** - * Reads each column as a string, and concatenates them together into a single string separated by | - */ - public static String readRowAsString(@NonNull Cursor cursor) { - StringBuilder row = new StringBuilder(); - - for (int i = 0, len = cursor.getColumnCount(); i < len; i++) { - row.append(cursor.getString(i)); - if (i < len - 1) { - row.append(" | "); - } - } - - return row.toString(); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CursorUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CursorUtil.kt new file mode 100644 index 0000000000..9cff8c77bc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CursorUtil.kt @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.util + +import android.database.Cursor + +fun Cursor.asSequence(): Sequence<Cursor> = + generateSequence { if (moveToNext()) this else null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java index 874440f5de..66c838cc1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java @@ -67,7 +67,8 @@ public class DateUtils extends android.text.format.DateUtils { } public static String getDisplayFormattedTimeSpanString(final Context c, final Locale locale, final long timestamp) { - if (isWithin(timestamp, 1, TimeUnit.MINUTES)) { + // If the timestamp is invalid (ie. 0) then assume we're waiting on data and just use the 'Now' copy + if (timestamp == 0 || isWithin(timestamp, 1, TimeUnit.MINUTES)) { return c.getString(R.string.DateUtils_just_now); } else if (isToday(timestamp)) { return getFormattedDateTime(timestamp, getHourFormat(c), locale); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt index 00e3e44418..9124765763 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GeneralUtilities.kt @@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.util import android.content.res.Resources import android.os.Build import androidx.annotation.ColorRes +import androidx.recyclerview.widget.RecyclerView +import kotlin.math.max import kotlin.math.roundToInt fun Resources.getColorWithID(@ColorRes id: Int, theme: Resources.Theme?): Int { @@ -30,3 +32,13 @@ fun toDp(px: Float, resources: Resources): Float { val scale = resources.displayMetrics.density return (px / scale) } + +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/GlowView.kt b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt index 08b81e5cb7..c7d53c1fef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt @@ -7,6 +7,7 @@ import android.graphics.Canvas import android.graphics.Paint import android.util.AttributeSet import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.annotation.ColorInt @@ -55,16 +56,21 @@ object GlowViewUtilities { animation.start() } - fun animateShadowColorChange(view: GlowView, @ColorInt startColor: Int, @ColorInt endColor: Int) { + fun animateShadowColorChange( + view: GlowView, + @ColorInt startColor: Int, + @ColorInt endColor: Int, + duration: Long = 250 + ) { val animation = ValueAnimator.ofObject(ArgbEvaluator(), startColor, endColor) - animation.duration = 250 + animation.duration = duration + animation.interpolator = AccelerateDecelerateInterpolator() animation.addUpdateListener { animator -> val color = animator.animatedValue as Int view.sessionShadowColor = color } animation.start() } - } class PNModeView : LinearLayout, GlowView { @@ -223,3 +229,59 @@ class InputBarButtonImageViewContainer : RelativeLayout, GlowView { } // endregion } + +class MessageBubbleView : androidx.constraintlayout.widget.ConstraintLayout, GlowView { + @ColorInt override var mainColor: Int = 0 + set(newValue) { field = newValue; paint.color = newValue } + @ColorInt override var sessionShadowColor: Int = 0 + set(newValue) { + field = newValue + shadowPaint.setShadowLayer(toPx(10, resources).toFloat(), 0.0f, 0.0f, newValue) + + if (numShadowRenders == 0) { + numShadowRenders = 1 + } + + invalidate() + } + var cornerRadius: Float = 0f + var numShadowRenders: Int = 0 + + private val paint: Paint by lazy { + val result = Paint() + result.style = Paint.Style.FILL + result.isAntiAlias = true + result + } + + private val shadowPaint: Paint by lazy { + val result = Paint() + result.style = Paint.Style.FILL + result.isAntiAlias = true + result + } + + // region Lifecycle + constructor(context: Context) : super(context) { } + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { } + + init { + setWillNotDraw(false) + } + // endregion + + // region Updating + override fun onDraw(c: Canvas) { + val w = width.toFloat() + val h = height.toFloat() + + (0 until numShadowRenders).forEach { + c.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, shadowPaint) + } + + c.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, paint) + super.onDraw(c) + } + // endregion +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/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 new file mode 100644 index 0000000000..4d00da8f96 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MockDataGenerator.kt @@ -0,0 +1,427 @@ +package org.thoughtcrime.securesms.util + +import android.content.Context +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.contacts.Contact +import org.session.libsession.messaging.messages.signal.IncomingTextMessage +import org.session.libsession.messaging.messages.signal.OutgoingTextMessage +import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupUtil +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.crypto.ecc.Curve +import org.session.libsignal.messages.SignalServiceGroup +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.guava.Optional +import org.session.libsignal.utilities.hexEncodedPublicKey +import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.groups.GroupManager +import java.security.SecureRandom +import kotlin.random.asKotlinRandom + +object MockDataGenerator { + private var printProgress = true + private var hasStartedGenerationThisRun = false + + // FIXME: Update this to run in a transaction instead of individual db writes (should drastically speed it up) + fun generateMockData(context: Context) { + // Don't re-generate the mock data if it already exists + val mockDataExistsRecipient = Recipient.from(context, Address.fromSerialized("MockDatabaseThread"), false) + val storage = MessagingModuleConfiguration.shared.storage + val threadDb = DatabaseComponent.get(context).threadDatabase() + val lokiThreadDB = DatabaseComponent.get(context).lokiThreadDatabase() + val contactDb = DatabaseComponent.get(context).sessionContactDatabase() + val recipientDb = DatabaseComponent.get(context).recipientDatabase() + val smsDb = DatabaseComponent.get(context).smsDatabase() + + if (hasStartedGenerationThisRun || threadDb.getThreadIdIfExistsFor(mockDataExistsRecipient) != -1L) { + hasStartedGenerationThisRun = true + return + } + + /// The mock data generation is quite slow, there are 3 parts which take a decent amount of time (deleting the account afterwards will + /// also take a long time): + /// Generating the threads & content - ~3m per 100 + val dmThreadCount: Int = 1000 + val closedGroupThreadCount: Int = 50 + val openGroupThreadCount: Int = 20 + val messageRangePerThread: List<IntRange> = listOf(0..500) + val dmRandomSeed: String = "1111" + val cgRandomSeed: String = "2222" + val ogRandomSeed: String = "3333" + val chunkSize: Int = 1000 // Chunk up the thread writing to prevent memory issues + val stringContent: List<String> = "abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 ".map { it.toString() } + val wordContent: List<String> = listOf("alias", "consequatur", "aut", "perferendis", "sit", "voluptatem", "accusantium", "doloremque", "aperiam", "eaque", "ipsa", "quae", "ab", "illo", "inventore", "veritatis", "et", "quasi", "architecto", "beatae", "vitae", "dicta", "sunt", "explicabo", "aspernatur", "aut", "odit", "aut", "fugit", "sed", "quia", "consequuntur", "magni", "dolores", "eos", "qui", "ratione", "voluptatem", "sequi", "nesciunt", "neque", "dolorem", "ipsum", "quia", "dolor", "sit", "amet", "consectetur", "adipisci", "velit", "sed", "quia", "non", "numquam", "eius", "modi", "tempora", "incidunt", "ut", "labore", "et", "dolore", "magnam", "aliquam", "quaerat", "voluptatem", "ut", "enim", "ad", "minima", "veniam", "quis", "nostrum", "exercitationem", "ullam", "corporis", "nemo", "enim", "ipsam", "voluptatem", "quia", "voluptas", "sit", "suscipit", "laboriosam", "nisi", "ut", "aliquid", "ex", "ea", "commodi", "consequatur", "quis", "autem", "vel", "eum", "iure", "reprehenderit", "qui", "in", "ea", "voluptate", "velit", "esse", "quam", "nihil", "molestiae", "et", "iusto", "odio", "dignissimos", "ducimus", "qui", "blanditiis", "praesentium", "laudantium", "totam", "rem", "voluptatum", "deleniti", "atque", "corrupti", "quos", "dolores", "et", "quas", "molestias", "excepturi", "sint", "occaecati", "cupiditate", "non", "provident", "sed", "ut", "perspiciatis", "unde", "omnis", "iste", "natus", "error", "similique", "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollitia", "animi", "id", "est", "laborum", "et", "dolorum", "fuga", "et", "harum", "quidem", "rerum", "facilis", "est", "et", "expedita", "distinctio", "nam", "libero", "tempore", "cum", "soluta", "nobis", "est", "eligendi", "optio", "cumque", "nihil", "impedit", "quo", "porro", "quisquam", "est", "qui", "minus", "id", "quod", "maxime", "placeat", "facere", "possimus", "omnis", "voluptas", "assumenda", "est", "omnis", "dolor", "repellendus", "temporibus", "autem", "quibusdam", "et", "aut", "consequatur", "vel", "illum", "qui", "dolorem", "eum", "fugiat", "quo", "voluptas", "nulla", "pariatur", "at", "vero", "eos", "et", "accusamus", "officiis", "debitis", "aut", "rerum", "necessitatibus", "saepe", "eveniet", "ut", "et", "voluptates", "repudiandae", "sint", "et", "molestiae", "non", "recusandae", "itaque", "earum", "rerum", "hic", "tenetur", "a", "sapiente", "delectus", "ut", "aut", "reiciendis", "voluptatibus", "maiores", "doloribus", "asperiores", "repellat") + val timestampNow: Long = System.currentTimeMillis() + val userSessionId: String = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! + val logProgress: ((String, String) -> Unit) = logProgress@{ title, event -> + if (!printProgress) { return@logProgress } + + Log.i("[MockDataGenerator]", "${System.currentTimeMillis()} $title - $event") + } + + hasStartedGenerationThisRun = true + + // FIXME: Make sure this data doesn't go off device somehow? + logProgress("", "Start") + + // First create the thread used to indicate that the mock data has been generated + threadDb.getOrCreateThreadIdFor(mockDataExistsRecipient) + + // -- DM Thread + val dmThreadRandomGenerator: SecureRandom = SecureRandom(dmRandomSeed.toByteArray()) + var dmThreadIndex: Int = 0 + logProgress("DM Threads", "Start Generating $dmThreadCount threads") + + while (dmThreadIndex < dmThreadCount) { + val remainingThreads: Int = (dmThreadCount - dmThreadIndex) + + (0 until Math.min(chunkSize, remainingThreads)).forEach { index -> + val threadIndex: Int = (dmThreadIndex + index) + + logProgress("DM Thread $threadIndex", "Start") + + val dataBytes = (0 until 16).map { dmThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() } + val randomSessionId: String = KeyPairUtilities.generate(dataBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey + val isMessageRequest: Boolean = dmThreadRandomGenerator.nextBoolean() + val contactNameLength: Int = (5 + dmThreadRandomGenerator.nextInt(15)) + + val numMessages: Int = ( + messageRangePerThread[threadIndex % messageRangePerThread.count()].first + + dmThreadRandomGenerator.nextInt(messageRangePerThread[threadIndex % messageRangePerThread.count()].last()) + ) + + // Generate the thread + val recipient = Recipient.from(context, Address.fromSerialized(randomSessionId), false) + val contact = Contact(randomSessionId) + val threadId = threadDb.getOrCreateThreadIdFor(recipient) + + // Generate the contact + val contactIsApproved: Boolean = (!isMessageRequest || dmThreadRandomGenerator.nextBoolean()) + contactDb.setContact(contact) + contactDb.setContactIsTrusted(contact, true, threadId) + recipientDb.setApproved(recipient, contactIsApproved) + recipientDb.setApprovedMe(recipient, (!isMessageRequest && (dmThreadRandomGenerator.nextInt(10) < 8))) // 80% approved the current user + + contact.name = (0 until dmThreadRandomGenerator.nextInt(contactNameLength)) + .map { stringContent.random(dmThreadRandomGenerator.asKotlinRandom()) } + .joinToString() + recipientDb.setProfileName(recipient, contact.name) + contactDb.setContact(contact) + + // Generate the message history (Note: Unapproved message requests will only include incoming messages) + logProgress("DM Thread $threadIndex", "Generate $numMessages Messages") + (0 until numMessages).forEach { index -> + val isIncoming: Boolean = ( + dmThreadRandomGenerator.nextBoolean() && + (!isMessageRequest || contactIsApproved) + ) + val messageWords: Int = (1 + dmThreadRandomGenerator.nextInt(19)) + + if (isIncoming) { + smsDb.insertMessageInbox( + IncomingTextMessage( + recipient.address, + 1, + (timestampNow - (index * 5000)), + (0 until messageWords) + .map { wordContent.random(dmThreadRandomGenerator.asKotlinRandom()) } + .joinToString(), + Optional.absent(), + 0, + 0, + false, + -1, + false + ), + (timestampNow - (index * 5000)), + false + ) + } + else { + smsDb.insertMessageOutbox( + threadId, + OutgoingTextMessage( + recipient, + (0 until messageWords) + .map { wordContent.random(dmThreadRandomGenerator.asKotlinRandom()) } + .joinToString(), + 0, + 0, + -1, + (timestampNow - (index * 5000)) + ), + (timestampNow - (index * 5000)), + false + ) + } + } + + logProgress("DM Thread $threadIndex", "Done") + } + logProgress("DM Threads", "Done") + + dmThreadIndex += chunkSize + } + logProgress("DM Threads", "Done") + + // -- Closed Group + + val cgThreadRandomGenerator: SecureRandom = SecureRandom(cgRandomSeed.toByteArray()) + var cgThreadIndex: Int = 0 + logProgress("Closed Group Threads", "Start Generating $closedGroupThreadCount threads") + + while (cgThreadIndex < closedGroupThreadCount) { + val remainingThreads: Int = (closedGroupThreadCount - cgThreadIndex) + + (0 until Math.min(chunkSize, remainingThreads)).forEach { index -> + val threadIndex: Int = (cgThreadIndex + index) + + logProgress("Closed Group Thread $threadIndex", "Start") + + val dataBytes = (0 until 16).map { cgThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() } + val randomGroupPublicKey: String = KeyPairUtilities.generate(dataBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey + val groupNameLength: Int = (5 + cgThreadRandomGenerator.nextInt(15)) + val groupName: String = (0 until groupNameLength) + .map { stringContent.random(cgThreadRandomGenerator.asKotlinRandom()) } + .joinToString() + val numGroupMembers: Int = cgThreadRandomGenerator.nextInt (10) + val numMessages: Int = ( + messageRangePerThread[threadIndex % messageRangePerThread.count()].first + + cgThreadRandomGenerator.nextInt(messageRangePerThread[threadIndex % messageRangePerThread.count()].last()) + ) + + // Generate the Contacts in the group + val members: MutableList<String> = mutableListOf(userSessionId) + logProgress("Closed Group Thread $threadIndex", "Generate $numGroupMembers Contacts") + + (0 until numGroupMembers).forEach { + val contactBytes = (0 until 16).map { cgThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() } + val randomSessionId: String = KeyPairUtilities.generate(contactBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey + val contactNameLength: Int = (5 + cgThreadRandomGenerator.nextInt(15)) + + val recipient = Recipient.from(context, Address.fromSerialized(randomSessionId), false) + val contact = Contact(randomSessionId) + contactDb.setContact(contact) + recipientDb.setApproved(recipient, true) + recipientDb.setApprovedMe(recipient, true) + + contact.name = (0 until cgThreadRandomGenerator.nextInt(contactNameLength)) + .map { stringContent.random(cgThreadRandomGenerator.asKotlinRandom()) } + .joinToString() + recipientDb.setProfileName(recipient, contact.name) + contactDb.setContact(contact) + members.add(randomSessionId) + } + + val groupId = GroupUtil.doubleEncodeGroupID(randomGroupPublicKey) + val threadId = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupId)) + val adminUserId = members.random(cgThreadRandomGenerator.asKotlinRandom()) + storage.createGroup( + groupId, + groupName, + members.map { Address.fromSerialized(it) }, + null, + null, + listOf(Address.fromSerialized(adminUserId)), + timestampNow + ) + storage.setProfileSharing(Address.fromSerialized(groupId), true) + storage.addClosedGroupPublicKey(randomGroupPublicKey) + + // 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.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 { + storage.insertIncomingInfoMessage(context, adminUserId, groupId, SignalServiceGroup.Type.CREATION, groupName, members, listOf(adminUserId), (timestampNow - (numMessages * 5000))) + } + + // Generate the message history (Note: Unapproved message requests will only include incoming messages) + logProgress("Closed Group Thread $threadIndex", "Generate $numMessages Messages") + + (0 until numGroupMembers).forEach { + val messageWords: Int = (1 + cgThreadRandomGenerator.nextInt(19)) + val senderId: String = members.random(cgThreadRandomGenerator.asKotlinRandom()) + + if (senderId != userSessionId) { + smsDb.insertMessageInbox( + IncomingTextMessage( + Address.fromSerialized(senderId), + 1, + (timestampNow - (index * 5000)), + (0 until messageWords) + .map { wordContent.random(cgThreadRandomGenerator.asKotlinRandom()) } + .joinToString(), + Optional.absent(), + 0, + 0, + false, + -1, + false + ), + (timestampNow - (index * 5000)), + false + ) + } + else { + smsDb.insertMessageOutbox( + threadId, + OutgoingTextMessage( + threadDb.getRecipientForThreadId(threadId), + (0 until messageWords) + .map { wordContent.random(cgThreadRandomGenerator.asKotlinRandom()) } + .joinToString(), + 0, + 0, + -1, + (timestampNow - (index * 5000)) + ), + (timestampNow - (index * 5000)), + false + ) + } + } + + logProgress("Closed Group Thread $threadIndex", "Done") + } + + cgThreadIndex += chunkSize + } + logProgress("Closed Group Threads", "Done") + + // --Open Group + + val ogThreadRandomGenerator: SecureRandom = SecureRandom(cgRandomSeed.toByteArray()) + var ogThreadIndex: Int = 0 + logProgress("Open Group Threads", "Start Generating $openGroupThreadCount threads") + + while (ogThreadIndex < openGroupThreadCount) { + val remainingThreads: Int = (openGroupThreadCount - ogThreadIndex) + + (0 until Math.min(chunkSize, remainingThreads)).forEach { index -> + val threadIndex: Int = (ogThreadIndex + index) + + logProgress("Open Group Thread $threadIndex", "Start") + + val dataBytes = (0 until 32).map { ogThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() } + val randomGroupPublicKey: String = KeyPairUtilities.generate(dataBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey + val serverNameLength: Int = (5 + ogThreadRandomGenerator.nextInt(15)) + val roomNameLength: Int = (5 + ogThreadRandomGenerator.nextInt(15)) + val roomDescriptionLength: Int = (10 + ogThreadRandomGenerator.nextInt(40)) + val serverName: String = (0 until serverNameLength) + .map { stringContent.random(ogThreadRandomGenerator.asKotlinRandom()) } + .joinToString() + val roomName: String = (0 until roomNameLength) + .map { stringContent.random(ogThreadRandomGenerator.asKotlinRandom()) } + .joinToString() + val roomDescription: String = (0 until roomDescriptionLength) + .map { stringContent.random(ogThreadRandomGenerator.asKotlinRandom()) } + .joinToString() + val numGroupMembers: Int = ogThreadRandomGenerator.nextInt(250) + val numMessages: Int = ( + messageRangePerThread[threadIndex % messageRangePerThread.count()].first + + ogThreadRandomGenerator.nextInt(messageRangePerThread[threadIndex % messageRangePerThread.count()].last()) + ) + + // Generate the Contacts in the group + val members: MutableList<String> = mutableListOf(userSessionId) + logProgress("Open Group Thread $threadIndex", "Generate $numGroupMembers Contacts") + + (0 until numGroupMembers).forEach { + val contactBytes = (0 until 16).map { ogThreadRandomGenerator.nextInt(UByte.MAX_VALUE.toInt()).toByte() } + val randomSessionId: String = KeyPairUtilities.generate(contactBytes.toByteArray()).x25519KeyPair.hexEncodedPublicKey + val contactNameLength: Int = (5 + ogThreadRandomGenerator.nextInt(15)) + + val recipient = Recipient.from(context, Address.fromSerialized(randomSessionId), false) + val contact = Contact(randomSessionId) + contactDb.setContact(contact) + recipientDb.setApproved(recipient, true) + recipientDb.setApprovedMe(recipient, true) + + contact.name = (0 until ogThreadRandomGenerator.nextInt(contactNameLength)) + .map { stringContent.random(cgThreadRandomGenerator.asKotlinRandom()) } + .joinToString() + recipientDb.setProfileName(recipient, contact.name) + contactDb.setContact(contact) + members.add(randomSessionId) + } + + // Create the open group model and the thread + val openGroupId = "$serverName.$roomName" + val threadId = GroupManager.createOpenGroup(openGroupId, context, null, roomName).threadId + val hasBlinding: Boolean = ogThreadRandomGenerator.nextBoolean() + + // Generate the capabilities and other data + storage.setOpenGroupPublicKey(serverName, randomGroupPublicKey) + storage.setServerCapabilities( + serverName, + ( + listOf(OpenGroupApi.Capability.SOGS.name.lowercase()) + + if (hasBlinding) { listOf(OpenGroupApi.Capability.BLIND.name.lowercase()) } else { emptyList() } + ) + ) + storage.setUserCount(roomName, serverName, numGroupMembers) + lokiThreadDB.setOpenGroupChat(OpenGroup(server = serverName, room = roomName, publicKey = randomGroupPublicKey, name = roomName, imageId = null, canWrite = true, infoUpdates = 0), threadId) + + // Generate the message history (Note: Unapproved message requests will only include incoming messages) + logProgress("Open Group Thread $threadIndex", "Generate $numMessages Messages") + + (0 until numMessages).forEach { index -> + val messageWords: Int = (1 + ogThreadRandomGenerator.nextInt(19)) + val senderId: String = members.random(ogThreadRandomGenerator.asKotlinRandom()) + + if (senderId != userSessionId) { + smsDb.insertMessageInbox( + IncomingTextMessage( + Address.fromSerialized(senderId), + 1, + (timestampNow - (index * 5000)), + (0 until messageWords) + .map { wordContent.random(ogThreadRandomGenerator.asKotlinRandom()) } + .joinToString(), + Optional.absent(), + 0, + 0, + false, + -1, + false + ), + (timestampNow - (index * 5000)), + false + ) + } else { + smsDb.insertMessageOutbox( + threadId, + OutgoingTextMessage( + threadDb.getRecipientForThreadId(threadId), + (0 until messageWords) + .map { wordContent.random(ogThreadRandomGenerator.asKotlinRandom()) } + .joinToString(), + 0, + 0, + -1, + (timestampNow - (index * 5000)) + ), + (timestampNow - (index * 5000)), + false + ) + } + } + + logProgress("Open Group Thread $threadIndex", "Done") + } + + ogThreadIndex += chunkSize + } + + logProgress("Open Group Threads", "Done") + logProgress("", "Complete") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/PopupMenuUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/PopupMenuUtil.kt deleted file mode 100644 index a1105cfff3..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/PopupMenuUtil.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.annotation.SuppressLint -import android.os.Build -import android.util.Log -import android.widget.PopupMenu - -@SuppressLint("PrivateApi") -@Deprecated(message = "Not needed when using appcompat 1.4.1+", replaceWith = ReplaceWith("setForceShowIcon(true)")) -fun PopupMenu.forceShowIcon() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - this.setForceShowIcon(true) - } else { - try { - val popupField = PopupMenu::class.java.getDeclaredField("mPopup") - popupField.isAccessible = true - val menu = popupField.get(this) - menu.javaClass.getDeclaredMethod("setForceShowIcon", Boolean::class.java) - .invoke(menu, true) - } catch (exception: Exception) { - Log.d("Loki", "Couldn't show message request popupmenu due to error: $exception.") - } - } -} 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 59658f12a0..8df2a7cd2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.util import android.content.ContentResolver import android.content.ContentValues import android.content.Context -import android.content.DialogInterface.OnClickListener import android.media.MediaScannerConnection import android.net.Uri import android.os.Build @@ -12,12 +11,12 @@ import android.provider.MediaStore import android.text.TextUtils import android.webkit.MimeTypeMap import android.widget.Toast -import androidx.appcompat.app.AlertDialog import network.loki.messenger.R import org.session.libsession.utilities.task.ProgressDialogAsyncTask import org.session.libsignal.utilities.ExternalStorageUtil import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.showSessionDialog import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -30,7 +29,12 @@ import java.util.concurrent.TimeUnit * Saves attachment files to an external storage using [MediaStore] API. * Requires [android.Manifest.permission.WRITE_EXTERNAL_STORAGE] on API 28 and below. */ -class SaveAttachmentTask : ProgressDialogAsyncTask<SaveAttachmentTask.Attachment, Void, Pair<Int, String?>> { +class SaveAttachmentTask @JvmOverloads constructor(context: Context, count: Int = 1) : + ProgressDialogAsyncTask<SaveAttachmentTask.Attachment, Void, Pair<Int, String?>>( + context, + context.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments, count, count), + context.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, count, count) + ) { companion object { @JvmStatic @@ -41,32 +45,28 @@ class SaveAttachmentTask : ProgressDialogAsyncTask<SaveAttachmentTask.Attachment @JvmStatic @JvmOverloads - fun showWarningDialog(context: Context, onAcceptListener: OnClickListener, count: Int = 1) { - val builder = AlertDialog.Builder(context) - builder.setTitle(R.string.ConversationFragment_save_to_sd_card) - builder.setIconAttribute(R.attr.dialog_alert_icon) - builder.setCancelable(true) - builder.setMessage(context.resources.getQuantityString( + fun showWarningDialog(context: Context, count: Int = 1, onAcceptListener: () -> Unit = {}) { + context.showSessionDialog { + title(R.string.ConversationFragment_save_to_sd_card) + iconAttribute(R.attr.dialog_alert_icon) + text(context.resources.getQuantityString( R.plurals.ConversationFragment_saving_n_media_to_storage_warning, count, count)) - builder.setPositiveButton(R.string.yes, onAcceptListener) - builder.setNegativeButton(R.string.no, null) - builder.show() + button(R.string.yes) { onAcceptListener() } + button(R.string.no) + } } } private val contextReference: WeakReference<Context> - private val attachmentCount: Int + private val attachmentCount: Int = count - @JvmOverloads - constructor(context: Context, count: Int = 1): super(context, - context.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments, count, count), - context.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, count, count)) { + init { this.contextReference = WeakReference(context) - this.attachmentCount = count } + @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") @@ -228,6 +228,7 @@ class SaveAttachmentTask : ProgressDialogAsyncTask<SaveAttachmentTask.Attachment 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/SessionMetaProtocol.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt index 05b6fe86f8..c10e1b635d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SessionMetaProtocol.kt @@ -49,11 +49,11 @@ object SessionMetaProtocol { @JvmStatic fun shouldSendReadReceipt(recipient: Recipient): Boolean { - return !recipient.isGroupRecipient && recipient.isApproved + return !recipient.isGroupRecipient && recipient.isApproved && !recipient.isBlocked } @JvmStatic fun shouldSendTypingIndicator(recipient: Recipient): Boolean { - return !recipient.isGroupRecipient && recipient.isApproved + return !recipient.isGroupRecipient && recipient.isApproved && !recipient.isBlocked } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt new file mode 100644 index 0000000000..3984f38b51 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SharedConfigUtils.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.util + +import network.loki.messenger.libsession_util.ConversationVolatileConfig +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.utilities.GroupUtil +import org.session.libsignal.utilities.IdPrefix +import org.thoughtcrime.securesms.database.model.ThreadRecord + +fun ConversationVolatileConfig.getConversationUnread(thread: ThreadRecord): Boolean { + val recipient = thread.recipient + if (recipient.isContactRecipient + && recipient.isOpenGroupInboxRecipient + && recipient.address.serialize().startsWith(IdPrefix.STANDARD.value)) { + return getOneToOne(recipient.address.serialize())?.unread == true + } else if (recipient.isClosedGroupRecipient) { + return getLegacyClosedGroup(GroupUtil.doubleDecodeGroupId(recipient.address.toGroupString()))?.unread == true + } else if (recipient.isCommunityRecipient) { + val openGroup = MessagingModuleConfiguration.shared.storage.getOpenGroup(thread.threadId) ?: return false + return getCommunity(openGroup.server, openGroup.room)?.unread == true + } + return false +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/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 7b7f3a04f3..c0477825fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -5,14 +5,22 @@ import android.animation.AnimatorListenerAdapter import android.animation.FloatEvaluator import android.animation.ValueAnimator import android.content.Context +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 { return hitRect.contains(point.x.toInt(), point.y.toInt()) @@ -28,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) @@ -54,7 +80,7 @@ fun View.fadeIn(duration: Long = 150) { fun View.fadeOut(duration: Long = 150) { animate().setDuration(duration).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) visibility = View.GONE } @@ -65,3 +91,23 @@ fun View.hideKeyboard() { val imm = this.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 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() + + return Bitmap.createBitmap(size.width, size.height, config).applyCanvas { + scale(scale, scale) + translate(-scrollX.toFloat(), -scrollY.toFloat()) + draw(this) + } +} + +fun Size.coerceAtMost(longestWidth: Int): Size = + (width.toFloat() / height).let { aspect -> + if (aspect > 1) { + width.coerceAtMost(longestWidth).let { Size(it, (it / aspect).roundToInt()) } + } else { + height.coerceAtMost(longestWidth).let { Size((it * aspect).roundToInt(), it) } + } + } 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/util/adapter/SelectableItem.kt b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/SelectableItem.kt new file mode 100644 index 0000000000..88b41d11cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/SelectableItem.kt @@ -0,0 +1,3 @@ +package org.thoughtcrime.securesms.util.adapter + +data class SelectableItem<T>(val item: T, val isSelected: Boolean) 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 006da2b63e..ff5e481895 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -1,7 +1,9 @@ package org.thoughtcrime.securesms.webrtc import android.content.Context +import android.content.pm.PackageManager import android.telephony.TelephonyManager +import androidx.core.content.ContextCompat import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.serialization.json.Json @@ -14,14 +16,17 @@ 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 import org.session.libsession.utilities.Address import org.session.libsession.utilities.Debouncer 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 @@ -54,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() @@ -90,6 +99,10 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va peerConnectionObservers.remove(listener) } + fun shutDownAudioManager() { + signalAudioManager.shutdown() + } + private val _audioEvents = MutableStateFlow(AudioEnabled(false)) val audioEvents = _audioEvents.asSharedFlow() private val _videoEvents = MutableStateFlow(VideoEnabled(false)) @@ -176,8 +189,22 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va _callStateEvents.value = newState } - fun isBusy(context: Context, callId: UUID) = callId != this.callId && (currentConnectionState != CallState.Idle - || context.getSystemService(TelephonyManager::class.java).callState != TelephonyManager.CALL_STATE_IDLE) + fun isBusy(context: Context, callId: UUID): Boolean { + // Make sure we have the permission before accessing the callState + if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) { + return ( + callId != this.callId && ( + currentConnectionState != CallState.Idle || + context.getSystemService(TelephonyManager::class.java).callState != TelephonyManager.CALL_STATE_IDLE + ) + ) + } + + return ( + callId != this.callId && + currentConnectionState != CallState.Idle + ) + } fun isPreOffer() = currentConnectionState == CallState.RemotePreOffer @@ -272,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) + 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) } } } } @@ -381,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) { @@ -398,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")) @@ -410,13 +442,11 @@ 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) + MessageSender.sendNonDurably(answerMessage, recipient.address, isSyncMessage = recipient.isLocalNumber) } else { Promise.ofFail(Exception("Couldn't reconnect from current state")) } @@ -458,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)) + MessageSender.sendNonDurably(answerMessage, Address.fromSerialized(userAddress), isSyncMessage = true) val sendAnswerMessage = MessageSender.sendNonDurably(CallMessage.answer( answer.description, callId - ), recipient.address) + ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_INCOMING, false) @@ -512,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).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).success { + ).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber).success { Log.d("Loki", "Sent offer") }.fail { Log.e("Loki", "Failed to send offer", it) @@ -534,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)) - MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address) + 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) } } @@ -554,11 +587,13 @@ 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) + + val thread = DatabaseComponent.get(context).threadDatabase().getOrCreateThreadIdFor(recipient) + MessageSender.sendNonDurably(CallMessage.endCall(callId).applyExpiryMode(thread), recipient.address, isSyncMessage = recipient.isLocalNumber) } } - fun insertCallMessage(threadPublicKey: String, callMessageType: CallMessageType, signal: Boolean = false, sentTimestamp: Long = System.currentTimeMillis()) { + fun insertCallMessage(threadPublicKey: String, callMessageType: CallMessageType, signal: Boolean = false, sentTimestamp: Long = SnodeAPI.nowWithOffset) { storage.insertCallMessage(threadPublicKey, callMessageType, sentTimestamp) } @@ -608,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). } } @@ -704,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) + 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/CallMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt index f007ace976..3d40b5f746 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallMessageProcessor.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.webrtc import android.app.NotificationManager import android.content.Context +import android.content.Intent import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope @@ -12,6 +13,7 @@ import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.utilities.WebRtcUtils +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 @@ -29,6 +31,24 @@ import org.webrtc.IceCandidate class CallMessageProcessor(private val context: Context, private val textSecurePreferences: TextSecurePreferences, lifecycle: Lifecycle, private val storage: StorageProtocol) { + companion object { + private const val VERY_EXPIRED_TIME = 15 * 60 * 1000L + + fun safeStartService(context: Context, intent: Intent) { + // If the foreground service crashes then it's possible for one of these intents to + // be started in the background (in which case 'startService' will throw a + // 'BackgroundServiceStartNotAllowedException' exception) so catch that case and try + // to re-start the service in the foreground + try { context.startService(intent) } + catch(e: Exception) { + try { ContextCompat.startForegroundService(context, intent) } + catch (e2: Exception) { + Log.e("Loki", "Unable to start CallMessage intent: ${e2.message}") + } + } + } + } + init { lifecycle.coroutineScope.launch(IO) { while (isActive) { @@ -53,6 +73,13 @@ class CallMessageProcessor(private val context: Context, private val textSecureP } continue } + + val isVeryExpired = (nextMessage.sentTimestamp?:0) + VERY_EXPIRED_TIME < SnodeAPI.nowWithOffset + if (isVeryExpired) { + Log.e("Loki", "Dropping very expired call message") + continue + } + when (nextMessage.type) { OFFER -> incomingCall(nextMessage) ANSWER -> incomingAnswer(nextMessage) @@ -78,7 +105,7 @@ class CallMessageProcessor(private val context: Context, private val textSecureP private fun incomingHangup(callMessage: CallMessage) { val callId = callMessage.callId ?: return val hangupIntent = WebRtcCallService.remoteHangupIntent(context, callId) - ContextCompat.startForegroundService(context, hangupIntent) + safeStartService(context, hangupIntent) } private fun incomingAnswer(callMessage: CallMessage) { @@ -91,7 +118,8 @@ class CallMessageProcessor(private val context: Context, private val textSecureP sdp = sdp, callId = callId ) - ContextCompat.startForegroundService(context, answerIntent) + + safeStartService(context, answerIntent) } private fun handleIceCandidates(callMessage: CallMessage) { @@ -107,7 +135,7 @@ class CallMessageProcessor(private val context: Context, private val textSecureP callId = callId, address = Address.fromSerialized(sender) ) - context.startService(iceIntent) + safeStartService(context, iceIntent) } private fun incomingPreOffer(callMessage: CallMessage) { @@ -120,7 +148,7 @@ class CallMessageProcessor(private val context: Context, private val textSecureP callId = callId, callTime = callMessage.sentTimestamp!! ) - ContextCompat.startForegroundService(context, incomingIntent) + safeStartService(context, incomingIntent) } private fun incomingCall(callMessage: CallMessage) { @@ -134,8 +162,7 @@ class CallMessageProcessor(private val context: Context, private val textSecureP callId = callId, callTime = callMessage.sentTimestamp!! ) - ContextCompat.startForegroundService(context, incomingIntent) - + safeStartService(context, incomingIntent) } private fun CallMessage.iceCandidates(): List<IceCandidate> { 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 955356c7d1..dbbbffc3e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallServiceReceivers.kt @@ -3,8 +3,11 @@ package org.thoughtcrime.securesms.webrtc import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.os.Build import android.telephony.PhoneStateListener +import android.telephony.TelephonyCallback import android.telephony.TelephonyManager +import androidx.annotation.RequiresApi import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.service.WebRtcCallService import org.thoughtcrime.securesms.webrtc.locks.LockManager @@ -16,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) { @@ -25,6 +29,21 @@ class HangUpRtcOnPstnCallAnsweredListener(private val hangupListener: ()->Unit): } } +@RequiresApi(Build.VERSION_CODES.S) +class HangUpRtcTelephonyCallback(private val hangupListener: ()->Unit): TelephonyCallback(), TelephonyCallback.CallStateListener { + + companion object { + private val TAG = Log.tag(HangUpRtcTelephonyCallback::class.java) + } + + override fun onCallStateChanged(state: Int) { + if (state == TelephonyManager.CALL_STATE_OFFHOOK) { + hangupListener() + Log.i(TAG, "Device phone call ended Session call.") + } + } +} + class PowerButtonReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (Intent.ACTION_SCREEN_OFF == intent.action) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioHandler.kt index 89eba2a3aa..7ca44f46dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioHandler.kt @@ -15,7 +15,7 @@ class SignalAudioHandler(looper: Looper) : Handler(looper) { } } - fun isOnHandler(): Boolean { + private fun isOnHandler(): Boolean { return Looper.myLooper() == looper } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt index 2b4d34807c..229cbd13dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt @@ -6,10 +6,10 @@ import android.content.Intent import android.content.IntentFilter import android.media.AudioManager import android.media.SoundPool -import android.os.Build import android.os.HandlerThread import network.loki.messenger.R import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.ThreadUtils import org.thoughtcrime.securesms.webrtc.AudioManagerCommand import org.thoughtcrime.securesms.webrtc.audio.SignalBluetoothManager.State as BState @@ -33,10 +33,10 @@ class SignalAudioManager(private val context: Context, private val eventListener: EventListener?, private val androidAudioManager: AudioManagerCompat) { - private var commandAndControlThread: HandlerThread? = HandlerThread("call-audio").apply { start() } - private var handler: SignalAudioHandler? = null + private var commandAndControlThread: HandlerThread? = HandlerThread("call-audio", ThreadUtils.PRIORITY_IMPORTANT_BACKGROUND_THREAD).apply { start() } + private var handler: SignalAudioHandler = SignalAudioHandler(commandAndControlThread!!.looper) - private var signalBluetoothManager: SignalBluetoothManager? = null + private var signalBluetoothManager: SignalBluetoothManager = SignalBluetoothManager(context, this, androidAudioManager, handler) private var state: State = State.UNINITIALIZED @@ -63,12 +63,9 @@ class SignalAudioManager(private val context: Context, private var wiredHeadsetReceiver: WiredHeadsetReceiver? = null fun handleCommand(command: AudioManagerCommand) { - if (command == AudioManagerCommand.Initialize) { - initialize() - return - } - handler?.post { + handler.post { when (command) { + is AudioManagerCommand.Initialize -> initialize() is AudioManagerCommand.UpdateAudioDeviceState -> updateAudioDeviceState() is AudioManagerCommand.Start -> start() is AudioManagerCommand.Stop -> stop(command.playDisconnect) @@ -85,34 +82,37 @@ class SignalAudioManager(private val context: Context, Log.i(TAG, "Initializing audio manager state: $state") if (state == State.UNINITIALIZED) { - commandAndControlThread = HandlerThread("call-audio").apply { start() } - handler = SignalAudioHandler(commandAndControlThread!!.looper) + savedAudioMode = androidAudioManager.mode + savedIsSpeakerPhoneOn = androidAudioManager.isSpeakerphoneOn + savedIsMicrophoneMute = androidAudioManager.isMicrophoneMute + hasWiredHeadset = androidAudioManager.isWiredHeadsetOn - signalBluetoothManager = SignalBluetoothManager(context, this, androidAudioManager, handler!!) + androidAudioManager.requestCallAudioFocus() - handler!!.post { + setMicrophoneMute(false) - savedAudioMode = androidAudioManager.mode - savedIsSpeakerPhoneOn = androidAudioManager.isSpeakerphoneOn - savedIsMicrophoneMute = androidAudioManager.isMicrophoneMute - hasWiredHeadset = androidAudioManager.isWiredHeadsetOn + audioDevices.clear() - androidAudioManager.requestCallAudioFocus() + signalBluetoothManager.start() - setMicrophoneMute(false) + updateAudioDeviceState() - audioDevices.clear() + wiredHeadsetReceiver = WiredHeadsetReceiver() + context.registerReceiver(wiredHeadsetReceiver, IntentFilter(AudioManager.ACTION_HEADSET_PLUG)) - signalBluetoothManager!!.start() + state = State.PREINITIALIZED - updateAudioDeviceState() + Log.d(TAG, "Initialized") + } + } - wiredHeadsetReceiver = WiredHeadsetReceiver() - context.registerReceiver(wiredHeadsetReceiver, IntentFilter(if (Build.VERSION.SDK_INT >= 21) AudioManager.ACTION_HEADSET_PLUG else Intent.ACTION_HEADSET_PLUG)) - - state = State.PREINITIALIZED - - Log.d(TAG, "Initialized") + fun shutdown() { + handler.post { + stop(false) + if (commandAndControlThread != null) { + Log.i(TAG, "Shutting down command and control") + commandAndControlThread?.quitSafely() + commandAndControlThread = null } } } @@ -139,23 +139,11 @@ class SignalAudioManager(private val context: Context, private fun stop(playDisconnect: Boolean) { Log.d(TAG, "Stopping. state: $state") - if (state == State.UNINITIALIZED) { - Log.i(TAG, "Trying to stop AudioManager in incorrect state: $state") - return - } - handler?.post { - incomingRinger.stop() - outgoingRinger.stop() - stop(false) - if (commandAndControlThread != null) { - Log.i(TAG, "Shutting down command and control") - commandAndControlThread?.quitSafely() - commandAndControlThread = null - } - } + incomingRinger.stop() + outgoingRinger.stop() - if (playDisconnect) { + if (playDisconnect && state != State.UNINITIALIZED) { val volume: Float = androidAudioManager.ringVolumeWithMinimum() soundPool.play(disconnectedSoundId, volume, volume, 0, 0, 1.0f) } @@ -171,7 +159,7 @@ class SignalAudioManager(private val context: Context, } wiredHeadsetReceiver = null - signalBluetoothManager?.stop() + signalBluetoothManager.stop() setSpeakerphoneOn(savedIsSpeakerPhoneOn) setMicrophoneMute(savedIsMicrophoneMute) @@ -184,25 +172,25 @@ class SignalAudioManager(private val context: Context, } private fun updateAudioDeviceState() { - handler!!.assertHandlerThread() + handler.assertHandlerThread() Log.i( TAG, "updateAudioDeviceState(): " + "wired: $hasWiredHeadset " + - "bt: ${signalBluetoothManager!!.state} " + + "bt: ${signalBluetoothManager.state} " + "available: $audioDevices " + "selected: $selectedAudioDevice " + "userSelected: $userSelectedAudioDevice" ) - if (signalBluetoothManager!!.state.shouldUpdate()) { - signalBluetoothManager!!.updateDevice() + if (signalBluetoothManager.state.shouldUpdate()) { + signalBluetoothManager.updateDevice() } val newAudioDevices = mutableSetOf(AudioDevice.SPEAKER_PHONE) - if (signalBluetoothManager!!.state.hasDevice()) { + if (signalBluetoothManager.state.hasDevice()) { newAudioDevices += AudioDevice.BLUETOOTH } @@ -218,7 +206,7 @@ class SignalAudioManager(private val context: Context, var audioDeviceSetUpdated = audioDevices != newAudioDevices audioDevices = newAudioDevices - if (signalBluetoothManager!!.state == BState.UNAVAILABLE && userSelectedAudioDevice == AudioDevice.BLUETOOTH) { + if (signalBluetoothManager.state == BState.UNAVAILABLE && userSelectedAudioDevice == AudioDevice.BLUETOOTH) { userSelectedAudioDevice = AudioDevice.NONE } @@ -231,7 +219,7 @@ class SignalAudioManager(private val context: Context, userSelectedAudioDevice = AudioDevice.NONE } - val btState = signalBluetoothManager!!.state + val btState = signalBluetoothManager.state val needBluetoothAudioStart = btState == BState.AVAILABLE && (userSelectedAudioDevice == AudioDevice.NONE || userSelectedAudioDevice == AudioDevice.BLUETOOTH || autoSwitchToBluetooth) @@ -239,27 +227,27 @@ class SignalAudioManager(private val context: Context, (userSelectedAudioDevice != AudioDevice.NONE && userSelectedAudioDevice != AudioDevice.BLUETOOTH) if (btState.hasDevice()) { - Log.i(TAG, "Need bluetooth audio: state: ${signalBluetoothManager!!.state} start: $needBluetoothAudioStart stop: $needBluetoothAudioStop") + Log.i(TAG, "Need bluetooth audio: state: ${signalBluetoothManager.state} start: $needBluetoothAudioStart stop: $needBluetoothAudioStop") } if (needBluetoothAudioStop) { - signalBluetoothManager!!.stopScoAudio() - signalBluetoothManager!!.updateDevice() + signalBluetoothManager.stopScoAudio() + signalBluetoothManager.updateDevice() } - if (!autoSwitchToBluetooth && signalBluetoothManager!!.state == BState.UNAVAILABLE) { + if (!autoSwitchToBluetooth && signalBluetoothManager.state == BState.UNAVAILABLE) { autoSwitchToBluetooth = true } - if (needBluetoothAudioStart && !needBluetoothAudioStop) { - if (!signalBluetoothManager!!.startScoAudio()) { + if (!needBluetoothAudioStop && needBluetoothAudioStart) { + if (!signalBluetoothManager.startScoAudio()) { Log.e(TAG,"Failed to start sco audio") audioDevices.remove(AudioDevice.BLUETOOTH) audioDeviceSetUpdated = true } } - if (autoSwitchToBluetooth && signalBluetoothManager!!.state == BState.CONNECTED) { + if (autoSwitchToBluetooth && signalBluetoothManager.state == BState.CONNECTED) { userSelectedAudioDevice = AudioDevice.BLUETOOTH autoSwitchToBluetooth = false } @@ -374,7 +362,7 @@ class SignalAudioManager(private val context: Context, val pluggedIn = intent.getIntExtra("state", 0) == 1 val hasMic = intent.getIntExtra("microphone", 0) == 1 - handler?.post { onWiredHeadsetChange(pluggedIn, hasMic) } + handler.post { onWiredHeadsetChange(pluggedIn, hasMic) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt index 84a36ee821..0a80cacef8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalBluetoothManager.kt @@ -2,14 +2,15 @@ package org.thoughtcrime.securesms.webrtc.audio import android.Manifest import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothHeadset import android.bluetooth.BluetoothProfile import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager import android.media.AudioManager +import androidx.core.app.ActivityCompat import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.webrtc.AudioManagerCommand import java.util.concurrent.TimeUnit @@ -80,7 +81,6 @@ class SignalBluetoothManager( bluetoothReceiver = BluetoothHeadsetBroadcastReceiver() context.registerReceiver(bluetoothReceiver, bluetoothHeadsetFilter) - Log.i(TAG, "Headset profile state: ${bluetoothAdapter?.getProfileConnectionState(BluetoothProfile.HEADSET)?.toStateString()}") Log.i(TAG, "Bluetooth proxy for headset profile has started") state = State.UNAVAILABLE } @@ -161,7 +161,8 @@ class SignalBluetoothManager( Log.d(TAG, "updateDevice(): state: $state") - if (state == State.UNINITIALIZED || bluetoothHeadset == null) { + if (state == State.UNINITIALIZED || bluetoothHeadset == null + || ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/LockManager.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/LockManager.java index a7fac62bbc..59c05af91b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/LockManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/locks/LockManager.java @@ -49,7 +49,7 @@ public class LockManager { partialLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "signal:partial"); proximityLock = new ProximityLock(pm); - WifiManager wm = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + WifiManager wm = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE); wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "signal:wifi"); fullLock.setReferenceCounted(false); 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/color/button_destructive.xml b/app/src/main/res/color/button_destructive.xml new file mode 100644 index 0000000000..cefbfed23a --- /dev/null +++ b/app/src/main/res/color/button_destructive.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_enabled="false" android:color="?android:textColorTertiary"/> + <item android:color="@color/destructive"/> +</selector> diff --git a/app/src/main/res/color/prominent_button_color.xml b/app/src/main/res/color/prominent_button_color.xml new file mode 100644 index 0000000000..39985565d1 --- /dev/null +++ b/app/src/main/res/color/prominent_button_color.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_enabled="false" android:color="@color/gray50"/> + <item android:color="?prominentButtonColor"/> +</selector> \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/ic_delivery_status_failed.png b/app/src/main/res/drawable-hdpi/ic_delivery_status_failed.png new file mode 100644 index 0000000000..c5de641a60 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_delivery_status_failed.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_delivery_status_read.png b/app/src/main/res/drawable-hdpi/ic_delivery_status_read.png index 307190cae6..bc55b7dfee 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_delivery_status_read.png and b/app/src/main/res/drawable-hdpi/ic_delivery_status_read.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_delivery_status_sending.png b/app/src/main/res/drawable-hdpi/ic_delivery_status_sending.png index fbdcef3583..1b8991d98b 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_delivery_status_sending.png and b/app/src/main/res/drawable-hdpi/ic_delivery_status_sending.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_delivery_status_sent.png b/app/src/main/res/drawable-hdpi/ic_delivery_status_sent.png index 72b685aee4..96a7b6340c 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_delivery_status_sent.png and b/app/src/main/res/drawable-hdpi/ic_delivery_status_sent.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_delivery_status_failed.png b/app/src/main/res/drawable-mdpi/ic_delivery_status_failed.png new file mode 100644 index 0000000000..b13352d079 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_delivery_status_failed.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_delivery_status_read.png b/app/src/main/res/drawable-mdpi/ic_delivery_status_read.png index eee83ef591..072dac7b30 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_delivery_status_read.png and b/app/src/main/res/drawable-mdpi/ic_delivery_status_read.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_delivery_status_sending.png b/app/src/main/res/drawable-mdpi/ic_delivery_status_sending.png index b34ea32b8b..f9b7fe3b79 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_delivery_status_sending.png and b/app/src/main/res/drawable-mdpi/ic_delivery_status_sending.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_delivery_status_sent.png b/app/src/main/res/drawable-mdpi/ic_delivery_status_sent.png index ff6bf0fac5..26fceea79c 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_delivery_status_sent.png and b/app/src/main/res/drawable-mdpi/ic_delivery_status_sent.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delivery_status_failed.png b/app/src/main/res/drawable-xhdpi/ic_delivery_status_failed.png new file mode 100644 index 0000000000..79aaa03f2b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_delivery_status_failed.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delivery_status_read.png b/app/src/main/res/drawable-xhdpi/ic_delivery_status_read.png index 79c4857f6c..af79508abf 100644 Binary files a/app/src/main/res/drawable-xhdpi/ic_delivery_status_read.png and b/app/src/main/res/drawable-xhdpi/ic_delivery_status_read.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delivery_status_sending.png b/app/src/main/res/drawable-xhdpi/ic_delivery_status_sending.png index aca7fe7ef3..74b8694dd4 100644 Binary files a/app/src/main/res/drawable-xhdpi/ic_delivery_status_sending.png and b/app/src/main/res/drawable-xhdpi/ic_delivery_status_sending.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_delivery_status_sent.png b/app/src/main/res/drawable-xhdpi/ic_delivery_status_sent.png index 811a54373d..094d8b34ce 100644 Binary files a/app/src/main/res/drawable-xhdpi/ic_delivery_status_sent.png and b/app/src/main/res/drawable-xhdpi/ic_delivery_status_sent.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_failed.png b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_failed.png new file mode 100644 index 0000000000..4ed4e2d8b1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_failed.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_read.png b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_read.png index 474a570724..69376c9a20 100644 Binary files a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_read.png and b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_read.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sending.png b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sending.png index 4a81c629d8..3dce4a05ea 100644 Binary files a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sending.png and b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sending.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sent.png b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sent.png index f679aaf244..302afb8370 100644 Binary files a/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sent.png and b/app/src/main/res/drawable-xxhdpi/ic_delivery_status_sent.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_failed.png b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_failed.png new file mode 100644 index 0000000000..6780212b8b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_failed.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_read.png b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_read.png index c138886aa3..d0b16705c0 100644 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_read.png and b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_read.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sending.png b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sending.png index 461c2ea636..411dc9d502 100644 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sending.png and b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sending.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sent.png b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sent.png index 47bd9acd33..657d454f6e 100644 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sent.png and b/app/src/main/res/drawable-xxxhdpi/ic_delivery_status_sent.png differ diff --git a/app/src/main/res/drawable/borderless_button_medium_background.xml b/app/src/main/res/drawable/borderless_button_medium_background.xml new file mode 100644 index 0000000000..6c72f9e72b --- /dev/null +++ b/app/src/main/res/drawable/borderless_button_medium_background.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> + +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?colorControlHighlight"> + <item android:id="@id/mask"> + <shape> + <solid android:color="?colorPrimary"/> + <corners android:radius="@dimen/medium_button_corner_radius" /> + </shape> + </item> +</ripple> 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/conversation_pinned_background.xml b/app/src/main/res/drawable/conversation_pinned_background.xml index 104b9c272e..eb64dc7f5f 100644 --- a/app/src/main/res/drawable/conversation_pinned_background.xml +++ b/app/src/main/res/drawable/conversation_pinned_background.xml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <ripple xmlns:android="http://schemas.android.com/apk/res/android" - android:color="?colorCellRipple"> + android:color="?android:colorControlHighlight"> <item> <color android:color="?conversation_pinned_background_color" /> diff --git a/app/src/main/res/drawable/conversation_unread_background.xml b/app/src/main/res/drawable/conversation_unread_background.xml index de0f5fb688..9e9bb94361 100644 --- a/app/src/main/res/drawable/conversation_unread_background.xml +++ b/app/src/main/res/drawable/conversation_unread_background.xml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <ripple xmlns:android="http://schemas.android.com/apk/res/android" - android:color="?colorCellRipple"> + android:color="?android:colorControlHighlight"> <item> <color android:color="?conversation_unread_background_color" /> diff --git a/app/src/main/res/drawable/conversation_view_background.xml b/app/src/main/res/drawable/conversation_view_background.xml index aaceb7ed54..2f177318e0 100644 --- a/app/src/main/res/drawable/conversation_view_background.xml +++ b/app/src/main/res/drawable/conversation_view_background.xml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <ripple xmlns:android="http://schemas.android.com/apk/res/android" - android:color="?colorCellRipple"> + android:color="?android:colorControlHighlight"> <item> <color android:color="?colorCellBackground" /> 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/default_dialog_background_inset.xml b/app/src/main/res/drawable/default_dialog_background_inset.xml deleted file mode 100644 index 0ff315ebd3..0000000000 --- a/app/src/main/res/drawable/default_dialog_background_inset.xml +++ /dev/null @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<inset - xmlns:android="http://schemas.android.com/apk/res/android" - android:drawable="@drawable/default_dialog_background" - android:inset="@dimen/medium_spacing"> -</inset> \ 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 1eff84a69b..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 @@ -1,11 +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="@color/transparent" /> - - <corners android:radius="@dimen/dialog_button_corner_radius" /> - - <stroke android:width="@dimen/border_thickness" android:color="@color/transparent" /> -</shape> \ No newline at end of file +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?colorControlHighlight"> + <item android:id="@android:id/mask"> + <shape android:shape="rectangle"> + <solid android:color="?android:textColorPrimary"/> + </shape> + </item> +</ripple> diff --git a/app/src/main/res/drawable/destructive_outline_button_medium_background.xml b/app/src/main/res/drawable/destructive_outline_button_medium_background.xml index 7db4da2ec4..c6e01ef98e 100644 --- a/app/src/main/res/drawable/destructive_outline_button_medium_background.xml +++ b/app/src/main/res/drawable/destructive_outline_button_medium_background.xml @@ -1,11 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> -<shape - xmlns:android="http://schemas.android.com/apk/res/android" - android:shape="rectangle"> - - <solid android:color="@color/transparent" /> - - <corners android:radius="@dimen/medium_button_corner_radius" /> - - <stroke android:width="@dimen/border_thickness" android:color="@color/destructive" /> -</shape> \ No newline at end of file +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="@color/button_destructive"> + <item> + <shape android:shape="rectangle"> + <solid android:color="?colorPrimary"/> + <corners android:radius="@dimen/medium_button_corner_radius" /> + <stroke + android:color="@color/button_destructive" + android:width="@dimen/border_thickness" /> + </shape> + </item> +</ripple> diff --git a/app/src/main/res/drawable/filled_button_medium_background.xml b/app/src/main/res/drawable/filled_button_medium_background.xml new file mode 100644 index 0000000000..10eb6de679 --- /dev/null +++ b/app/src/main/res/drawable/filled_button_medium_background.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?android:textColorPrimary"> + <item> + <shape android:shape="rectangle"> + <solid android:color="?colorDividerBackground"/> + <corners android:radius="16dp" /> + </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_expand.xml b/app/src/main/res/drawable/ic_expand.xml new file mode 100644 index 0000000000..3b2b816a45 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand.xml @@ -0,0 +1,5 @@ +<vector android:autoMirrored="true" android:height="27dp" + android:viewportHeight="27" android:viewportWidth="26" + android:width="26dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="#ffffff" android:pathData="M7.38,12.265C7.792,12.265 8.093,11.962 8.093,11.55V11.079L7.957,9.008L9.494,10.629L11.394,12.542C11.528,12.682 11.703,12.746 11.893,12.746C12.336,12.746 12.654,12.448 12.654,12.009C12.654,11.807 12.58,11.627 12.441,11.489L10.533,9.588L8.911,8.052L10.995,8.188H11.497C11.909,8.188 12.217,7.892 12.217,7.476C12.217,7.058 11.915,6.758 11.497,6.758H7.849C7.097,6.758 6.662,7.193 6.662,7.944V11.55C6.662,11.957 6.969,12.265 7.38,12.265ZM14.497,19.444H18.146C18.897,19.444 19.338,19.009 19.338,18.257V14.65C19.338,14.245 19.031,13.937 18.614,13.937C18.208,13.937 17.901,14.24 17.901,14.65V15.123L18.043,17.193L16.5,15.572L14.605,13.66C14.472,13.52 14.291,13.456 14.101,13.456C13.664,13.456 13.34,13.754 13.34,14.191C13.34,14.394 13.42,14.574 13.559,14.712L15.461,16.613L17.089,18.149L15.005,18.013H14.497C14.086,18.013 13.777,18.309 13.777,18.726C13.777,19.144 14.086,19.444 14.497,19.444Z"/> +</vector> diff --git a/app/src/main/res/drawable/ic_filled_circle_check.xml b/app/src/main/res/drawable/ic_filled_circle_check.xml index 06c3466fd0..99589252b1 100644 --- a/app/src/main/res/drawable/ic_filled_circle_check.xml +++ b/app/src/main/res/drawable/ic_filled_circle_check.xml @@ -8,6 +8,6 @@ android:fillColor="?android:textColorPrimary" android:pathData="M6.5,6.5m-6.5,0a6.5,6.5 0,1 1,13 0a6.5,6.5 0,1 1,-13 0"/> <path - android:fillColor="?android:textColorPrimaryInverse" + android:fillColor="?colorPrimary" android:pathData="M3.77,6.61c-0.15,-0.15 -0.38,-0.15 -0.53,0c-0.15,0.15 -0.15,0.38 0,0.53l1.88,1.88c0.15,0.15 0.38,0.15 0.53,0L9.78,4.9c0.15,-0.15 0.15,-0.38 0,-0.53c-0.15,-0.15 -0.38,-0.15 -0.53,0L5.38,8.22L3.77,6.61z"/> </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_launcher_foreground_monochrome.xml b/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml new file mode 100644 index 0000000000..5f21434d45 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml @@ -0,0 +1,15 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="1018.39685" + android:viewportHeight="1019.1061"> + <group android:translateX="307.15576" + android:translateY="285.3497"> + <group> + <clip-path android:pathData="M0,0L404.085,0L404.085,448.407L0,448.407Z M 0,0"/> + <path android:fillAlpha="1" android:fillColor="#000000" + android:fillType="nonZero" + android:pathData="m288.607,420.376l-196.335,-0c-33.576,-0 -62.508,-25.748 -64.164,-59.281 -1.771,-35.847 26.883,-65.576 62.353,-65.576l113.072,-0c6.919,-0 12.527,-5.608 12.527,-12.525l0,-92.305L327.307,252.333C356.723,268.633 375.241,299.335 376.027,332.848 377.161,380.975 336.746,420.376 288.607,420.376m-211.829,-224.303c-29.416,-16.3 -47.933,-47.001 -48.721,-80.515 -1.132,-48.127 39.283,-87.528 87.42,-87.528L311.811,28.031c33.576,-0 62.508,25.748 64.165,59.283 1.771,35.845 -26.883,65.575 -62.352,65.575 0,-0 -81.316,0.013 -113.077,0.019 -6.915,0.001 -12.499,5.608 -12.501,12.523l-0.021,92.289zM340.891,227.816 L256.254,180.919l57.371,-0c49.877,-0 90.46,-40.579 90.46,-90.457 0,-49.877 -40.583,-90.461 -90.46,-90.461l-200.299,-0c-62.485,-0 -113.327,50.841 -113.327,113.327 0,44.567 24.216,85.664 63.195,107.265l84.636,46.896l-57.368,-0c-49.88,-0 -90.463,40.58 -90.463,90.457 0,49.877 40.583,90.461 90.463,90.461L290.758,448.407c62.488,-0 113.327,-50.84 113.327,-113.327 0,-44.567 -24.216,-85.664 -63.193,-107.264" android:strokeColor="#00000000"/> + </group> + </group> +</vector> diff --git a/app/src/main/res/drawable/ic_message_details__refresh.xml b/app/src/main/res/drawable/ic_message_details__refresh.xml new file mode 100644 index 0000000000..2aabe6fbe3 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_details__refresh.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="26dp" + android:height="21dp" + android:viewportWidth="26" + android:viewportHeight="21"> + <path + android:pathData="M13.468,20.291C15.669,20.291 17.795,19.548 19.307,18.33C20.22,17.627 20.414,16.646 19.821,15.943C19.207,15.229 18.34,15.224 17.526,15.794C16.296,16.745 15.074,17.235 13.468,17.235C10.109,17.235 7.327,15 6.532,12.011H8.261C9.138,12.011 9.378,11.134 8.868,10.451L5.96,6.434C5.449,5.74 4.581,5.695 4.055,6.434L1.184,10.451C0.674,11.15 0.899,12.011 1.776,12.011H3.556C4.435,16.889 8.431,20.291 13.468,20.291ZM13.438,0.291C11.255,0.291 9.111,1.019 7.617,2.238C6.7,2.94 6.509,3.921 7.102,4.624C7.717,5.338 8.584,5.34 9.38,4.773C10.612,3.837 11.835,3.332 13.438,3.332C16.8,3.332 19.579,5.567 20.392,8.556H18.57C17.678,8.556 17.45,9.432 17.948,10.116L20.871,14.133C21.382,14.827 22.249,14.872 22.775,14.133L25.647,10.116C26.156,9.432 25.932,8.556 25.04,8.556H23.35C22.485,3.675 18.492,0.291 13.438,0.291Z" + android:fillColor="#ffffff"/> +</vector> diff --git a/app/src/main/res/drawable/ic_message_details__reply.xml b/app/src/main/res/drawable/ic_message_details__reply.xml new file mode 100644 index 0000000000..c9e1591a53 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_details__reply.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="26dp" + android:height="26dp" + android:viewportWidth="26" + android:viewportHeight="26"> + <path + android:pathData="M10.847,3.572V7.974C20.432,8.707 23.521,16.919 23.868,20.933C19.76,14.869 13.476,14.412 10.847,14.942V19.466L2.962,11.702L10.847,3.572Z" + android:fillColor="#ffffff"/> +</vector> diff --git a/app/src/main/res/drawable/ic_message_details__trash.xml b/app/src/main/res/drawable/ic_message_details__trash.xml new file mode 100644 index 0000000000..85d4216958 --- /dev/null +++ b/app/src/main/res/drawable/ic_message_details__trash.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="26dp" + android:height="26dp" + android:viewportWidth="26" + android:viewportHeight="26"> + <path + android:pathData="M8.749,24.43H19.167C21.186,24.43 22.073,23.433 22.385,21.427L23.972,5.825L22.071,5.907L20.492,21.315C20.35,22.226 19.89,22.591 19.076,22.591H8.847C8.017,22.591 7.566,22.226 7.432,21.315L5.853,5.907L3.952,5.825L5.539,21.427C5.843,23.441 6.738,24.43 8.749,24.43ZM4.063,6.85H23.863C25.195,6.85 25.962,5.998 25.962,4.677V3.244C25.962,1.924 25.195,1.072 23.863,1.072H4.063C2.782,1.072 1.962,1.924 1.962,3.244V4.677C1.962,5.998 2.732,6.85 4.063,6.85ZM4.44,5.102C3.99,5.102 3.794,4.898 3.794,4.446V3.474C3.794,3.023 3.99,2.819 4.44,2.819H23.492C23.942,2.819 24.13,3.023 24.13,3.474V4.446C24.13,4.898 23.942,5.102 23.492,5.102H4.44Z" + android:fillColor="#FF3A3A"/> +</vector> 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_next.xml b/app/src/main/res/drawable/ic_next.xml new file mode 100644 index 0000000000..1e72d86cb6 --- /dev/null +++ b/app/src/main/res/drawable/ic_next.xml @@ -0,0 +1,8 @@ +<vector android:autoMirrored="true" android:height="17dp" + android:viewportHeight="17" android:viewportWidth="13" + android:width="13dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <group> + <clip-path android:pathData="M13,16.004l-13,-0l-0,-16l13,-0z"/> + <path android:fillColor="#ffffff" android:pathData="M0.646,1.736L10.112,7.933L0.444,14.268C0.323,14.343 0.222,14.438 0.144,14.547C0.067,14.657 0.015,14.779 -0.007,14.906C-0.029,15.033 -0.022,15.163 0.014,15.287C0.05,15.412 0.115,15.529 0.203,15.632C0.292,15.734 0.404,15.82 0.532,15.885C0.66,15.95 0.801,15.991 0.948,16.008C1.095,16.024 1.244,16.015 1.386,15.981C1.529,15.946 1.662,15.887 1.778,15.808L12.353,8.88C12.466,8.805 12.562,8.711 12.635,8.605C12.687,8.563 12.734,8.518 12.778,8.47C12.955,8.266 13.031,8.009 12.99,7.756C12.949,7.503 12.794,7.274 12.559,7.12L1.984,0.193C1.868,0.117 1.736,0.061 1.595,0.029C1.454,-0.003 1.307,-0.011 1.163,0.006C1.018,0.024 0.88,0.066 0.754,0.13C0.628,0.194 0.519,0.279 0.431,0.381C0.343,0.482 0.278,0.597 0.241,0.72C0.204,0.843 0.195,0.971 0.215,1.097C0.235,1.223 0.284,1.344 0.358,1.454C0.432,1.563 0.53,1.659 0.646,1.736Z"/> + </group> +</vector> 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/ic_pictures.xml b/app/src/main/res/drawable/ic_pictures.xml new file mode 100644 index 0000000000..967d0a65b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_pictures.xml @@ -0,0 +1,18 @@ + <vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="44dp" + android:height="35dp" + android:viewportWidth="44" + android:viewportHeight="35"> + <path + android:pathData="M35.45,23.4L27.129,17.313C27.074,17.269 27.007,17.245 26.937,17.245C26.867,17.245 26.799,17.269 26.744,17.313L19.283,23.49C19.226,23.532 19.156,23.555 19.084,23.555C19.013,23.555 18.943,23.532 18.885,23.49L15.135,20.433C15.082,20.393 15.017,20.371 14.949,20.371C14.882,20.371 14.817,20.393 14.763,20.433L4.606,27.65C4.567,27.681 4.535,27.72 4.513,27.764C4.49,27.808 4.478,27.857 4.477,27.907V30.244C4.481,30.437 4.559,30.621 4.695,30.758C4.832,30.895 5.016,30.973 5.209,30.976H34.847C35.041,30.976 35.227,30.899 35.364,30.762C35.501,30.624 35.579,30.438 35.579,30.244V23.644C35.58,23.595 35.569,23.548 35.546,23.505C35.524,23.462 35.491,23.426 35.45,23.4Z" + android:fillColor="#A1A2A1"/> + <path + android:pathData="M11.63,18.25C13.226,18.25 14.519,16.957 14.519,15.361C14.519,13.765 13.226,12.472 11.63,12.472C10.034,12.472 8.741,13.765 8.741,15.361C8.741,16.957 10.034,18.25 11.63,18.25Z" + android:fillColor="#A1A2A1"/> + <path + android:pathData="M40.895,4.125L8.728,0.375C8.306,0.324 7.879,0.356 7.47,0.471C7.062,0.587 6.68,0.782 6.348,1.046C6.016,1.31 5.739,1.638 5.535,2.01C5.331,2.382 5.202,2.791 5.158,3.213L5.004,4.6H7.572L7.7,3.509C7.711,3.425 7.738,3.345 7.78,3.272C7.822,3.199 7.878,3.136 7.944,3.085C8.054,2.997 8.189,2.947 8.33,2.944H8.394L22.686,4.6H36.221C37.011,4.605 37.789,4.791 38.495,5.145C39.201,5.499 39.815,6.012 40.291,6.642H40.599C40.768,6.661 40.922,6.746 41.028,6.879C41.133,7.011 41.183,7.18 41.165,7.348L41.075,8.08C41.254,8.597 41.349,9.139 41.357,9.685V27.997L43.72,7.682C43.814,6.836 43.57,5.988 43.04,5.321C42.511,4.655 41.74,4.225 40.895,4.125Z" + android:fillColor="#A1A2A1"/> + <path + android:pathData="M36.221,34.803H3.835C2.984,34.803 2.167,34.464 1.565,33.862C0.963,33.26 0.625,32.444 0.625,31.592V9.762C0.625,8.911 0.963,8.094 1.565,7.492C2.167,6.89 2.984,6.552 3.835,6.552H36.221C37.072,6.552 37.889,6.89 38.491,7.492C39.093,8.094 39.431,8.911 39.431,9.762V31.592C39.431,32.444 39.093,33.26 38.491,33.862C37.889,34.464 37.072,34.803 36.221,34.803ZM3.835,9.095C3.665,9.095 3.502,9.162 3.381,9.283C3.261,9.403 3.193,9.566 3.193,9.737V31.567C3.193,31.737 3.261,31.9 3.381,32.021C3.502,32.141 3.665,32.209 3.835,32.209H36.221C36.391,32.209 36.554,32.141 36.675,32.021C36.795,31.9 36.863,31.737 36.863,31.567V9.737C36.863,9.566 36.795,9.403 36.675,9.283C36.554,9.162 36.391,9.095 36.221,9.095H3.835Z" + android:fillColor="#A1A2A1"/> +</vector> diff --git a/app/src/main/res/drawable/ic_prev.xml b/app/src/main/res/drawable/ic_prev.xml new file mode 100644 index 0000000000..f720261670 --- /dev/null +++ b/app/src/main/res/drawable/ic_prev.xml @@ -0,0 +1,8 @@ +<vector android:autoMirrored="true" android:height="17dp" + android:viewportHeight="17" android:viewportWidth="12" + android:width="12dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <group> + <clip-path android:pathData="M0,0.004h12v16h-12z"/> + <path android:fillColor="#ffffff" android:pathData="M11.403,14.272L2.666,8.075L11.59,1.74C11.701,1.665 11.795,1.57 11.867,1.46C11.938,1.351 11.986,1.229 12.006,1.102C12.027,0.975 12.02,0.845 11.987,0.721C11.954,0.596 11.894,0.479 11.812,0.376C11.73,0.274 11.627,0.187 11.509,0.123C11.391,0.058 11.26,0.016 11.125,0C10.989,-0.016 10.852,-0.007 10.72,0.027C10.589,0.062 10.466,0.12 10.359,0.2L0.597,7.127C0.493,7.203 0.405,7.297 0.337,7.403C0.289,7.444 0.245,7.49 0.205,7.538C0.042,7.742 -0.029,7.999 0.009,8.252C0.047,8.505 0.19,8.734 0.407,8.887L10.168,15.815C10.275,15.891 10.398,15.947 10.528,15.979C10.658,16.011 10.793,16.019 10.927,16.001C11.06,15.984 11.188,15.942 11.304,15.878C11.42,15.814 11.521,15.728 11.602,15.627C11.684,15.526 11.743,15.411 11.777,15.288C11.811,15.165 11.82,15.037 11.801,14.911C11.783,14.785 11.738,14.664 11.67,14.554C11.601,14.445 11.511,14.349 11.403,14.272Z"/> + </group> +</vector> diff --git a/app/src/main/res/drawable/mention_candidate_view_background.xml b/app/src/main/res/drawable/mention_candidate_view_background.xml index 7b179020aa..4e9785a41e 100644 --- a/app/src/main/res/drawable/mention_candidate_view_background.xml +++ b/app/src/main/res/drawable/mention_candidate_view_background.xml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <ripple xmlns:android="http://schemas.android.com/apk/res/android" - android:color="?mention_candidates_view_background_ripple"> + android:color="?android:colorControlHighlight"> <item> <color android:color="?mention_candidates_view_background" /> diff --git a/app/src/main/res/drawable/preference_bottom.xml b/app/src/main/res/drawable/preference_bottom.xml index d751c7841b..b6c5f506fd 100644 --- a/app/src/main/res/drawable/preference_bottom.xml +++ b/app/src/main/res/drawable/preference_bottom.xml @@ -1,5 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> -<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?colorControlHighlight"> <item android:left="@dimen/medium_spacing" android:right="@dimen/medium_spacing" android:bottom="@dimen/small_spacing" @@ -10,4 +11,4 @@ android:bottomRightRadius="?preferenceCornerRadius"/> </shape> </item> -</layer-list> \ No newline at end of file +</ripple> \ No newline at end of file diff --git a/app/src/main/res/drawable/preference_middle.xml b/app/src/main/res/drawable/preference_middle.xml index 0f7229cb7f..bf27aacc72 100644 --- a/app/src/main/res/drawable/preference_middle.xml +++ b/app/src/main/res/drawable/preference_middle.xml @@ -1,5 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> -<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?colorControlHighlight"> <item android:left="@dimen/medium_spacing" android:right="@dimen/medium_spacing"> <shape android:shape="rectangle"> @@ -14,4 +15,4 @@ <solid android:color="@color/transparent_white_15"/> </shape> </item> -</layer-list> \ No newline at end of file +</ripple> \ No newline at end of file diff --git a/app/src/main/res/drawable/preference_single.xml b/app/src/main/res/drawable/preference_single.xml index da856fcd18..7caf24a084 100644 --- a/app/src/main/res/drawable/preference_single.xml +++ b/app/src/main/res/drawable/preference_single.xml @@ -1,5 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> -<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?colorControlHighlight"> <item android:left="@dimen/medium_spacing" android:top="@dimen/small_spacing" android:right="@dimen/medium_spacing" @@ -9,4 +10,4 @@ <corners android:radius="?preferenceCornerRadius"/> </shape> </item> -</layer-list> \ No newline at end of file +</ripple> \ No newline at end of file diff --git a/app/src/main/res/drawable/preference_top.xml b/app/src/main/res/drawable/preference_top.xml index a997713e23..8f56ddc870 100644 --- a/app/src/main/res/drawable/preference_top.xml +++ b/app/src/main/res/drawable/preference_top.xml @@ -1,5 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> -<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?colorControlHighlight"> <item android:left="@dimen/medium_spacing" android:right="@dimen/medium_spacing" android:top="@dimen/small_spacing" @@ -18,4 +19,4 @@ <solid android:color="@color/transparent_white_15"/> </shape> </item> -</layer-list> \ No newline at end of file +</ripple> diff --git a/app/src/main/res/drawable/profile_picture_view_large_background.xml b/app/src/main/res/drawable/profile_picture_view_large_background.xml index 278d70901f..9b90660803 100644 --- a/app/src/main/res/drawable/profile_picture_view_large_background.xml +++ b/app/src/main/res/drawable/profile_picture_view_large_background.xml @@ -5,5 +5,5 @@ <solid android:color="@color/profile_picture_background" /> - <corners android:radius="38dp" /> + <corners android:radius="40dp" /> </shape> \ No newline at end of file diff --git a/app/src/main/res/drawable/prominent_filled_button_medium_background.xml b/app/src/main/res/drawable/prominent_filled_button_medium_background.xml index a06a0d11e5..698a67c0a9 100644 --- a/app/src/main/res/drawable/prominent_filled_button_medium_background.xml +++ b/app/src/main/res/drawable/prominent_filled_button_medium_background.xml @@ -1,11 +1,10 @@ <?xml version="1.0" encoding="utf-8"?> -<shape - xmlns:android="http://schemas.android.com/apk/res/android" - android:shape="rectangle"> - - <solid android:color="?colorAccent" /> - - <corners android:radius="@dimen/medium_button_corner_radius" /> - - <stroke android:width="@dimen/border_thickness" android:color="?colorAccent" /> -</shape> \ No newline at end of file +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?colorPrimary"> + <item> + <shape android:shape="rectangle"> + <solid android:color="?colorAccent"/> + <corners android:radius="@dimen/medium_button_corner_radius" /> + </shape> + </item> +</ripple> diff --git a/app/src/main/res/drawable/prominent_outline_button_medium_background.xml b/app/src/main/res/drawable/prominent_outline_button_medium_background.xml index ee3bec8f7f..4bde2f855c 100644 --- a/app/src/main/res/drawable/prominent_outline_button_medium_background.xml +++ b/app/src/main/res/drawable/prominent_outline_button_medium_background.xml @@ -1,11 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> -<shape - xmlns:android="http://schemas.android.com/apk/res/android" - android:shape="rectangle"> - - <solid android:color="@color/transparent" /> - - <corners android:radius="@dimen/medium_button_corner_radius" /> - - <stroke android:width="@dimen/border_thickness" android:color="?prominentButtonColor" /> -</shape> \ No newline at end of file +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?prominentButtonColor"> + <item> + <shape android:shape="rectangle"> + <solid android:color="?colorPrimary"/> + <corners android:radius="@dimen/medium_button_corner_radius" /> + <stroke + android:color="?prominentButtonColor" + android:width="@dimen/border_thickness" /> + </shape> + </item> +</ripple> diff --git a/app/src/main/res/drawable/radial_select.xml b/app/src/main/res/drawable/radial_select.xml index a56519ed6e..e09c778d0c 100644 --- a/app/src/main/res/drawable/radial_select.xml +++ b/app/src/main/res/drawable/radial_select.xml @@ -1,12 +1,21 @@ <?xml version="1.0" encoding="utf-8"?> -<selector xmlns:android="http://schemas.android.com/apk/res/android"> - <item android:state_selected="true"> +<ripple xmlns:android="http://schemas.android.com/apk/res/android" android:color="?colorControlHighlight"> + <item> + <selector> + <item android:state_selected="true"> + <shape android:shape="oval"> + <stroke android:width="1dp" android:color="?android:textColorPrimary"/> + </shape> + </item> + <item android:state_selected="false"> + <shape android:shape="oval"> + </shape> + </item> + </selector> + </item> + <item android:id="@android:id/mask"> <shape android:shape="oval"> - <stroke android:width="1dp" android:color="?android:textColorPrimary"/> + <solid android:color="@color/black"/> </shape> </item> - <item android:state_selected="false"> - <shape android:shape="oval"> - </shape> - </item> -</selector> \ No newline at end of file +</ripple> diff --git a/app/src/main/res/drawable/setting_button_background.xml b/app/src/main/res/drawable/setting_button_background.xml index aaceb7ed54..2f177318e0 100644 --- a/app/src/main/res/drawable/setting_button_background.xml +++ b/app/src/main/res/drawable/setting_button_background.xml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <ripple xmlns:android="http://schemas.android.com/apk/res/android" - android:color="?colorCellRipple"> + android:color="?android:colorControlHighlight"> <item> <color android:color="?colorCellBackground" /> 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 1eff84a69b..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 @@ -1,11 +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="@color/transparent" /> - - <corners android:radius="@dimen/dialog_button_corner_radius" /> - - <stroke android:width="@dimen/border_thickness" android:color="@color/transparent" /> -</shape> \ No newline at end of file +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?colorControlHighlight"> + <item android:id="@android:id/mask"> + <shape android:shape="rectangle"> + <solid android:color="?android:textColorPrimary"/> + </shape> + </item> +</ripple> diff --git a/app/src/main/res/drawable/unimportant_outline_button_medium_background.xml b/app/src/main/res/drawable/unimportant_outline_button_medium_background.xml index 6e0de35a5b..d12f7408d9 100644 --- a/app/src/main/res/drawable/unimportant_outline_button_medium_background.xml +++ b/app/src/main/res/drawable/unimportant_outline_button_medium_background.xml @@ -1,11 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> -<shape - xmlns:android="http://schemas.android.com/apk/res/android" - android:shape="rectangle"> - - <solid android:color="@color/transparent" /> - - <corners android:radius="@dimen/medium_button_corner_radius" /> - - <stroke android:width="@dimen/border_thickness" android:color="?android:textColorPrimary" /> -</shape> \ No newline at end of file +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?android:textColorPrimary"> + <item> + <shape android:shape="rectangle"> + <solid android:color="?colorPrimary"/> + <corners android:radius="@dimen/medium_button_corner_radius" /> + <stroke + android:color="?android:textColorPrimary" + android:width="@dimen/border_thickness" /> + </shape> + </item> +</ripple> diff --git a/app/src/main/res/drawable/view_separator.xml b/app/src/main/res/drawable/view_separator.xml new file mode 100644 index 0000000000..27dd4bc967 --- /dev/null +++ b/app/src/main/res/drawable/view_separator.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> + <stroke android:color="?colorDividerBackground" android:width="1dp"/> + <corners android:radius="16dp"/> + <solid android:color="?colorPrimary"/> +</shape> \ No newline at end of file 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 50986e7dd4..d62faca064 100644 --- a/app/src/main/res/layout-sw400dp/activity_display_name.xml +++ b/app/src/main/res/layout-sw400dp/activity_display_name.xml @@ -33,6 +33,7 @@ <EditText style="@style/SessionEditText" android:id="@+id/displayNameEditText" + android:contentDescription="@string/AccessibilityId_enter_display_name" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginLeft="@dimen/very_large_spacing" @@ -42,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 @@ -52,6 +55,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentFilled" android:id="@+id/registerButton" + android:contentDescription="@string/AccessibilityId_continue" android:layout_width="match_parent" android:layout_height="@dimen/medium_button_height" android:layout_marginLeft="@dimen/massive_spacing" diff --git a/app/src/main/res/layout-sw400dp/activity_landing.xml b/app/src/main/res/layout-sw400dp/activity_landing.xml index d1cbc8f2f3..5e5a36704a 100644 --- a/app/src/main/res/layout-sw400dp/activity_landing.xml +++ b/app/src/main/res/layout-sw400dp/activity_landing.xml @@ -32,6 +32,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentFilled" + android:contentDescription="@string/AccessibilityId_create_session_id" android:id="@+id/registerButton" android:layout_width="match_parent" android:layout_height="@dimen/medium_button_height" @@ -42,6 +43,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentOutline" android:id="@+id/restoreButton" + android:contentDescription="@string/AccessibilityId_restore_your_session" android:layout_width="match_parent" android:layout_height="@dimen/medium_button_height" android:layout_marginLeft="@dimen/massive_spacing" @@ -50,13 +52,14 @@ android:text="@string/activity_landing_restore_button_title" /> <Button + style="@style/Widget.Session.Button.Common.Borderless" android:id="@+id/linkButton" + android:contentDescription="@string/AccessibilityId_link_a_device" 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:background="@color/transparent" android:textAllCaps="false" android:textSize="@dimen/medium_font_size" android:text="@string/activity_link_device_link_device" /> diff --git a/app/src/main/res/layout-sw400dp/activity_pn_mode.xml b/app/src/main/res/layout-sw400dp/activity_pn_mode.xml index 1e43529500..b55b35f149 100644 --- a/app/src/main/res/layout-sw400dp/activity_pn_mode.xml +++ b/app/src/main/res/layout-sw400dp/activity_pn_mode.xml @@ -19,6 +19,7 @@ android:textSize="@dimen/very_large_font_size" android:textStyle="bold" android:textColor="?android:textColorPrimary" + android:contentDescription="@string/AccessibilityId_message_notifications" android:text="@string/activity_pn_mode_message_notifications" /> <TextView @@ -32,6 +33,7 @@ android:text="@string/activity_pn_mode_explanation" /> <org.thoughtcrime.securesms.util.PNModeView + android:contentDescription="@string/AccessibilityId_fast_mode_notifications_option" android:id="@+id/fcmOptionView" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -70,6 +72,7 @@ </org.thoughtcrime.securesms.util.PNModeView> <org.thoughtcrime.securesms.util.PNModeView + android:contentDescription="@string/AccessibilityId_slow_mode_notifications_option" android:id="@+id/backgroundPollingOptionView" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -86,7 +89,8 @@ android:textSize="@dimen/medium_font_size" android:textColor="?android:textColorPrimary" android:textStyle="bold" - android:text="@string/activity_pn_mode_slow_mode" /> + android:text="@string/activity_pn_mode_slow_mode" + /> <TextView android:layout_width="match_parent" @@ -101,6 +105,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentFilled" android:id="@+id/registerButton" + android:contentDescription="@string/AccessibilityId_continue_message_notifications" android:layout_width="match_parent" android:layout_height="@dimen/medium_button_height" android:layout_marginLeft="@dimen/massive_spacing" 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 f801235b4f..0000000000 --- a/app/src/main/res/layout-sw400dp/activity_recovery_phrase_restore.xml +++ /dev/null @@ -1,76 +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: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: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-sw400dp/activity_register.xml b/app/src/main/res/layout-sw400dp/activity_register.xml index 9d424ae188..b642bb292c 100644 --- a/app/src/main/res/layout-sw400dp/activity_register.xml +++ b/app/src/main/res/layout-sw400dp/activity_register.xml @@ -33,6 +33,7 @@ <TextView style="@style/SessionIDTextView" + android:contentDescription="@string/AccessibilityId_session_id" android:id="@+id/publicKeyTextView" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -49,6 +50,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentFilled" android:id="@+id/registerButton" + android:contentDescription="@string/AccessibilityId_continue" android:layout_width="match_parent" android:layout_height="@dimen/medium_button_height" android:layout_marginLeft="@dimen/massive_spacing" diff --git a/app/src/main/res/layout-sw400dp/activity_seed.xml b/app/src/main/res/layout-sw400dp/activity_seed.xml index 186415ee85..97b63ede5e 100644 --- a/app/src/main/res/layout-sw400dp/activity_seed.xml +++ b/app/src/main/res/layout-sw400dp/activity_seed.xml @@ -39,6 +39,7 @@ <TextView style="@style/SessionIDTextView" android:id="@+id/seedTextView" + android:contentDescription="@string/AccessibilityId_recovery_phrase" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginLeft="@dimen/very_large_spacing" @@ -49,15 +50,14 @@ android:textAlignment="center" tools:text="nautical novelty populate onion awkward bent etiquette plant submarine itches vipers september axis maximum populate" /> - <TextView + <Button android:id="@+id/revealButton" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="12dp" + android:layout_width="196dp" + android:layout_height="@dimen/onboarding_button_bottom_offset" android:textAlignment="center" android:textSize="16sp" - android:textColor="?android:textColorPrimary" android:alpha="0.6" + style="@style/Widget.Session.Button.Common.Borderless" android:text="@string/activity_seed_reveal_button_title" /> <View @@ -67,6 +67,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentOutline" + android:contentDescription="@string/AccessibilityId_copy_recovery_phrase" android:id="@+id/copyButton" android:layout_width="196dp" android:layout_height="@dimen/medium_button_height" diff --git a/app/src/main/res/layout-sw400dp/fragment_recovery_phrase.xml b/app/src/main/res/layout-sw400dp/fragment_recovery_phrase.xml index 9a51b14519..7b220207e2 100644 --- a/app/src/main/res/layout-sw400dp/fragment_recovery_phrase.xml +++ b/app/src/main/res/layout-sw400dp/fragment_recovery_phrase.xml @@ -33,6 +33,7 @@ <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" @@ -53,6 +54,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentFilled" android:id="@+id/continueButton" + android:contentDescription="@string/AccessibilityId_continue" android:layout_width="match_parent" android:layout_height="@dimen/medium_button_height" android:layout_marginLeft="@dimen/massive_spacing" diff --git a/app/src/main/res/layout-sw400dp/view_seed_reminder.xml b/app/src/main/res/layout-sw400dp/view_seed_reminder.xml index 21ab47579c..5be50ebe6c 100644 --- a/app/src/main/res/layout-sw400dp/view_seed_reminder.xml +++ b/app/src/main/res/layout-sw400dp/view_seed_reminder.xml @@ -59,6 +59,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentOutline" android:id="@+id/button" + android:contentDescription="@string/AccessibilityId_continue" android:layout_width="wrap_content" android:layout_height="28dp" android:layout_marginLeft="4dp" diff --git a/app/src/main/res/layout/activity_appearance_settings.xml b/app/src/main/res/layout/activity_appearance_settings.xml index fbafa632d5..ce33af82f1 100644 --- a/app/src/main/res/layout/activity_appearance_settings.xml +++ b/app/src/main/res/layout/activity_appearance_settings.xml @@ -11,6 +11,7 @@ android:orientation="vertical" android:layout_width="match_parent" android:layout_height="wrap_content"> + <TextView android:textColor="?android:textColorTertiary" android:textSize="@dimen/medium_font_size" @@ -20,127 +21,170 @@ android:layout_height="wrap_content" android:text="@string/activity_appearance_themes_category"/> - <LinearLayout - android:id="@+id/theme_option_classic_dark" - android:background="@drawable/preference_top" + <androidx.cardview.widget.CardView + app:cardElevation="0dp" + app:cardCornerRadius="@dimen/dialog_corner_radius" + android:layout_marginHorizontal="@dimen/medium_spacing" + app:cardBackgroundColor="?colorSettingsBackground" android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingHorizontal="@dimen/large_spacing" - android:paddingVertical="@dimen/medium_spacing" - android:orientation="horizontal"> - <ImageView - android:theme="@style/Classic.Dark" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:src="@drawable/ic_themepreview"/> - <TextView - android:textAppearance="@android:style/TextAppearance.DeviceDefault.Medium" - android:lines="1" - android:textSize="16sp" - android:text="@string/classic_dark_theme_name" - android:padding="@dimen/medium_spacing" - android:layout_gravity="center" - android:layout_width="0dp" - android:layout_weight="1" - android:layout_height="wrap_content"/> - <RadioButton - android:id="@+id/theme_radio_classic_dark" - android:layout_gravity="center" - android:layout_margin="@dimen/small_spacing" - android:layout_width="wrap_content" - android:layout_height="wrap_content"/> - </LinearLayout> - <LinearLayout - android:id="@+id/theme_option_classic_light" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="@drawable/preference_middle" - android:paddingHorizontal="@dimen/large_spacing" - android:paddingVertical="@dimen/small_spacing" - android:orientation="horizontal"> - <ImageView - android:theme="@style/Classic.Light" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:src="@drawable/ic_themepreview"/> - <TextView - android:textAppearance="@android:style/TextAppearance.DeviceDefault.Medium" - android:textSize="16sp" - android:lines="1" - android:text="@string/classic_light_theme_name" - android:padding="@dimen/medium_spacing" - android:layout_gravity="center" - android:layout_width="0dp" - android:layout_weight="1" - android:layout_height="wrap_content"/> - <RadioButton - android:id="@+id/theme_radio_classic_light" - android:layout_gravity="center" - android:layout_margin="@dimen/small_spacing" - android:layout_width="wrap_content" - android:layout_height="wrap_content"/> - </LinearLayout> - <LinearLayout - android:id="@+id/theme_option_ocean_dark" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="@drawable/preference_middle" - android:paddingHorizontal="@dimen/large_spacing" - android:paddingVertical="@dimen/medium_spacing" - android:orientation="horizontal"> - <ImageView - android:theme="@style/Ocean.Dark" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:src="@drawable/ic_themepreview"/> - <TextView - android:textAppearance="@android:style/TextAppearance.DeviceDefault.Medium" - android:textSize="16sp" - android:lines="1" - android:text="@string/ocean_dark_theme_name" - android:padding="@dimen/medium_spacing" - android:layout_gravity="center" - android:layout_width="0dp" - android:layout_weight="1" - android:layout_height="wrap_content"/> - <RadioButton - android:id="@+id/theme_radio_ocean_dark" - android:layout_gravity="center" - android:layout_margin="@dimen/small_spacing" - android:layout_width="wrap_content" - android:layout_height="wrap_content"/> - </LinearLayout> - <LinearLayout - android:id="@+id/theme_option_ocean_light" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="@drawable/preference_bottom" - android:paddingHorizontal="@dimen/large_spacing" - android:paddingTop="@dimen/medium_spacing" - android:paddingBottom="@dimen/large_spacing" - android:orientation="horizontal"> - <ImageView - android:theme="@style/Ocean.Light" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:src="@drawable/ic_themepreview"/> - <TextView - android:textAppearance="@android:style/TextAppearance.DeviceDefault.Medium" - android:textSize="16sp" - android:lines="1" - android:text="@string/ocean_light_theme_name" - android:padding="@dimen/medium_spacing" - android:layout_gravity="center" - android:layout_width="0dp" - android:layout_weight="1" - android:layout_height="wrap_content"/> - <RadioButton - android:id="@+id/theme_radio_ocean_light" - android:layout_gravity="center" - android:layout_margin="@dimen/small_spacing" - android:layout_width="wrap_content" - android:layout_height="wrap_content"/> - </LinearLayout> + android:layout_height="wrap_content"> + <LinearLayout + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <LinearLayout + android:id="@+id/theme_option_classic_dark" + android:addStatesFromChildren="true" + android:background="?selectableItemBackground" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingHorizontal="@dimen/small_spacing" + android:paddingVertical="@dimen/small_spacing" + android:orientation="horizontal"> + <ImageView + android:theme="@style/Classic.Dark" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:src="@drawable/ic_themepreview"/> + <TextView + android:textAppearance="@android:style/TextAppearance.DeviceDefault.Medium" + android:lines="1" + android:textSize="16sp" + android:text="@string/classic_dark_theme_name" + android:padding="@dimen/medium_spacing" + android:layout_gravity="center" + android:layout_width="0dp" + android:layout_weight="1" + android:layout_height="wrap_content"/> + <RadioButton + android:id="@+id/theme_radio_classic_dark" + android:layout_gravity="center" + android:layout_margin="@dimen/small_spacing" + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + </LinearLayout> + + <View + android:alpha="0.15" + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?android:textColorPrimary"/> + + <LinearLayout + android:id="@+id/theme_option_classic_light" + android:addStatesFromChildren="true" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?selectableItemBackground" + android:paddingHorizontal="@dimen/small_spacing" + android:paddingVertical="@dimen/small_spacing" + android:orientation="horizontal"> + <ImageView + android:theme="@style/Classic.Light" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:src="@drawable/ic_themepreview"/> + <TextView + android:textAppearance="@android:style/TextAppearance.DeviceDefault.Medium" + android:textSize="16sp" + android:lines="1" + android:text="@string/classic_light_theme_name" + android:padding="@dimen/medium_spacing" + android:layout_gravity="center" + android:layout_width="0dp" + android:layout_weight="1" + android:layout_height="wrap_content"/> + <RadioButton + android:id="@+id/theme_radio_classic_light" + android:layout_gravity="center" + android:layout_margin="@dimen/small_spacing" + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + </LinearLayout> + + <View + android:alpha="0.15" + android:layout_marginHorizontal="@dimen/medium_spacing" + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?android:textColorPrimary"/> + + <LinearLayout + android:id="@+id/theme_option_ocean_dark" + android:addStatesFromChildren="true" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?selectableItemBackground" + android:paddingHorizontal="@dimen/small_spacing" + android:paddingVertical="@dimen/small_spacing" + android:orientation="horizontal"> + <ImageView + android:theme="@style/Ocean.Dark" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:src="@drawable/ic_themepreview"/> + <TextView + android:textAppearance="@android:style/TextAppearance.DeviceDefault.Medium" + android:textSize="16sp" + android:lines="1" + android:text="@string/ocean_dark_theme_name" + android:padding="@dimen/medium_spacing" + android:layout_gravity="center" + android:layout_width="0dp" + android:layout_weight="1" + android:layout_height="wrap_content"/> + <RadioButton + android:id="@+id/theme_radio_ocean_dark" + android:layout_gravity="center" + android:layout_margin="@dimen/small_spacing" + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + </LinearLayout> + + <View + android:alpha="0.15" + android:layout_marginHorizontal="@dimen/medium_spacing" + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?android:textColorPrimary"/> + + <LinearLayout + android:id="@+id/theme_option_ocean_light" + android:addStatesFromChildren="true" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?selectableItemBackground" + android:paddingHorizontal="@dimen/small_spacing" + android:paddingVertical="@dimen/small_spacing" + android:orientation="horizontal"> + <ImageView + android:theme="@style/Ocean.Light" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:src="@drawable/ic_themepreview"/> + <TextView + android:textAppearance="@android:style/TextAppearance.DeviceDefault.Medium" + android:textSize="16sp" + android:lines="1" + android:text="@string/ocean_light_theme_name" + android:padding="@dimen/medium_spacing" + android:layout_gravity="center" + android:layout_width="0dp" + android:layout_weight="1" + android:layout_height="wrap_content"/> + <RadioButton + android:id="@+id/theme_radio_ocean_light" + android:layout_gravity="center" + android:layout_margin="@dimen/small_spacing" + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + </LinearLayout> + + </LinearLayout> + </androidx.cardview.widget.CardView> + + + <TextView android:textColor="?android:textColorTertiary" android:textSize="@dimen/medium_font_size" @@ -208,8 +252,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" - android:layout_marginVertical="@dimen/medium_spacing" - android:layout_marginHorizontal="@dimen/large_spacing"> + android:layout_marginVertical="@dimen/small_spacing" + android:layout_marginHorizontal="@dimen/small_spacing"> <View android:id="@+id/accent_green" android:background="@drawable/padded_circle_tintable" @@ -270,29 +314,39 @@ android:layout_height="wrap_content" android:text="@string/activity_appearance_follow_system_category"/> - <LinearLayout - android:padding="@dimen/medium_spacing" + <androidx.cardview.widget.CardView + app:cardElevation="0dp" + android:elevation="0dp" + app:cardBackgroundColor="?colorSettingsBackground" + app:cardCornerRadius="@dimen/dialog_corner_radius" + android:layout_margin="@dimen/medium_spacing" android:layout_marginBottom="@dimen/massive_spacing" - android:background="@drawable/preference_single" android:layout_width="match_parent" - android:layout_height="wrap_content" - android:gravity="center"> - <TextView - android:textColor="?android:textColorPrimary" - android:textStyle="bold" - android:textSize="@dimen/medium_font_size" - android:paddingHorizontal="@dimen/large_spacing" - android:paddingVertical="@dimen/small_spacing" - android:text="@string/activity_appearance_follow_system_explanation" - android:layout_width="0dp" + android:layout_height="wrap_content"> + <LinearLayout + android:id="@+id/system_settings_switch_holder" + android:background="?selectableItemBackground" + android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_weight="1"/> - <androidx.appcompat.widget.SwitchCompat - android:id="@+id/system_settings_switch" - android:paddingHorizontal="@dimen/large_spacing" - android:layout_width="wrap_content" - android:layout_height="wrap_content"/> - </LinearLayout> + android:addStatesFromChildren="true" + android:gravity="center"> + <TextView + android:textColor="?android:textColorPrimary" + android:textStyle="bold" + android:textSize="@dimen/medium_font_size" + android:paddingHorizontal="@dimen/large_spacing" + android:paddingVertical="@dimen/small_spacing" + android:text="@string/activity_appearance_follow_system_explanation" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1"/> + <com.google.android.material.switchmaterial.SwitchMaterial + android:id="@+id/system_settings_switch" + android:paddingHorizontal="@dimen/large_spacing" + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + </LinearLayout> + </androidx.cardview.widget.CardView> </LinearLayout> </ScrollView> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_blocked_contacts.xml b/app/src/main/res/layout/activity_blocked_contacts.xml index 69d0043009..f02ad7cb31 100644 --- a/app/src/main/res/layout/activity_blocked_contacts.xml +++ b/app/src/main/res/layout/activity_blocked_contacts.xml @@ -4,28 +4,37 @@ android:layout_height="match_parent" xmlns:app="http://schemas.android.com/apk/res-auto"> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/recyclerView" + <androidx.cardview.widget.CardView + android:id="@+id/cardView" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toTopOf="@+id/unblockButton" - android:layout_width="match_parent" - android:layout_height="0dp" - android:background="@drawable/preference_single_no_padding" - app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + app:cardCornerRadius="?preferenceCornerRadius" + app:cardElevation="0dp" + app:cardBackgroundColor="?colorSettingsBackground" android:layout_marginHorizontal="@dimen/medium_spacing" android:layout_marginVertical="@dimen/large_spacing" - /> + android:layout_width="match_parent" + android:layout_height="0dp"> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/recyclerView" + android:layout_width="match_parent" + android:layout_height="match_parent" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + /> + + </androidx.cardview.widget.CardView> + <TextView android:id="@+id/emptyStateMessageTextView" android:visibility="gone" android:layout_width="wrap_content" android:layout_height="wrap_content" - app:layout_constraintTop_toTopOf="@+id/recyclerView" + app:layout_constraintTop_toTopOf="@+id/cardView" android:layout_marginTop="@dimen/medium_spacing" - app:layout_constraintStart_toStartOf="@+id/recyclerView" - app:layout_constraintEnd_toEndOf="@+id/recyclerView" + app:layout_constraintStart_toStartOf="@+id/cardView" + app:layout_constraintEnd_toEndOf="@+id/cardView" android:text="@string/blocked_contacts_empty_state" /> @@ -38,7 +47,7 @@ android:layout_height="wrap_content" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@+id/recyclerView" + app:layout_constraintTop_toBottomOf="@+id/cardView" android:id="@+id/unblockButton" app:layout_constraintBottom_toBottomOf="parent" android:layout_marginVertical="@dimen/large_spacing" @@ -49,6 +58,6 @@ android:id="@+id/nonEmptyStateGroup" android:layout_width="wrap_content" android:layout_height="wrap_content" - app:constraint_referenced_ids="unblockButton,recyclerView"/> + app:constraint_referenced_ids="unblockButton,cardView"/> </androidx.constraintlayout.widget.ConstraintLayout> \ 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 ae7aa3c689..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,13 +35,14 @@ android:layout_above="@+id/typingIndicatorViewContainer" android:layout_below="@id/toolbar" /> + <org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer android:focusable="false" android:id="@+id/typingIndicatorViewContainer" android:layout_width="match_parent" android:layout_height="36dp" android:visibility="gone" - android:layout_above="@+id/messageRequestBar" + android:layout_above="@+id/textSendAfterApproval" /> <org.thoughtcrime.securesms.conversation.v2.input_bar.InputBar @@ -89,6 +96,7 @@ android:id="@+id/gifButtonContainer" android:layout_width="@dimen/input_bar_button_expanded_size" android:layout_height="@dimen/input_bar_button_expanded_size" + android:contentDescription="@string/AccessibilityId_gif_button" android:alpha="0" /> <RelativeLayout @@ -96,6 +104,7 @@ android:layout_marginTop="8dp" android:layout_width="@dimen/input_bar_button_expanded_size" android:layout_height="@dimen/input_bar_button_expanded_size" + android:contentDescription="@string/AccessibilityId_documents_folder" android:alpha="0" /> <RelativeLayout @@ -103,6 +112,7 @@ android:layout_marginTop="8dp" android:layout_width="@dimen/input_bar_button_expanded_size" android:layout_height="@dimen/input_bar_button_expanded_size" + android:contentDescription="@string/AccessibilityId_images_folder" android:alpha="0" /> <RelativeLayout @@ -110,10 +120,24 @@ android:layout_marginTop="8dp" android:layout_width="@dimen/input_bar_button_expanded_size" android:layout_height="@dimen/input_bar_button_expanded_size" + android:contentDescription="@string/AccessibilityId_select_camera_button" android:alpha="0" /> </LinearLayout> + <TextView + android:id="@+id/textSendAfterApproval" + android:text="@string/ConversationActivity_send_after_approval" + android:visibility="gone" + android:textAlignment="center" + android:textColor="@color/classic_light_2" + android:padding="22dp" + android:textSize="12sp" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignWithParentIfMissing="true" + android:layout_above="@id/messageRequestBar"/> + <RelativeLayout android:id="@+id/scrollToBottomButton" android:visibility="gone" @@ -121,6 +145,7 @@ android:layout_height="50dp" android:layout_alignParentEnd="true" android:layout_above="@+id/messageRequestBar" + android:layout_alignWithParentIfMissing="true" android:layout_marginEnd="12dp" android:layout_marginBottom="32dp"> @@ -176,6 +201,7 @@ <RelativeLayout android:id="@+id/blockedBanner" + android:contentDescription="@string/AccessibilityId_blocked_banner" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_below="@+id/toolbar" @@ -185,6 +211,7 @@ <TextView android:id="@+id/blockedBannerTextView" + android:contentDescription="@string/AccessibilityId_blocked_banner_text" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" @@ -196,6 +223,42 @@ </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" + android:textColor="?android:textColorSecondary" + android:textAlignment="center" + android:id="@+id/placeholderText" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_below="@+id/blockedBanner" + android:elevation="8dp" + tools:text="@string/activity_conversation_empty_state_default" + /> + <LinearLayout android:id="@+id/messageRequestBar" android:layout_width="match_parent" @@ -208,14 +271,15 @@ <TextView android:id="@+id/messageRequestBlock" + android:layout_width="wrap_content" + android:layout_height="wrap_content" android:layout_gravity="center" + android:contentDescription="@string/AccessibilityId_block_message_request_button" android:textColor="@color/destructive" android:paddingHorizontal="@dimen/massive_spacing" android:paddingVertical="@dimen/small_spacing" android:textSize="@dimen/text_size" - android:text="@string/activity_conversation_block_user" - android:layout_width="wrap_content" - android:layout_height="wrap_content"/> + android:text="@string/activity_conversation_block_user"/> <TextView android:id="@+id/sendAcceptsTextView" @@ -237,6 +301,7 @@ <Button android:id="@+id/acceptMessageRequestButton" style="@style/Widget.Session.Button.Common.ProminentOutline" + android:contentDescription="@string/AccessibilityId_accept_message_request_button" android:layout_width="0dp" android:layout_height="@dimen/medium_button_height" android:layout_weight="1" @@ -245,6 +310,7 @@ <Button android:id="@+id/declineMessageRequestButton" style="@style/Widget.Session.Button.Common.DestructiveOutline" + android:contentDescription="@string/AccessibilityId_decline_message_request_button" android:layout_width="0dp" android:layout_height="@dimen/medium_button_height" android:layout_marginStart="@dimen/medium_spacing" 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 840963609b..0000000000 --- a/app/src/main/res/layout/activity_conversation_v2_action_bar.xml +++ /dev/null @@ -1,67 +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"> - - <include layout="@layout/view_profile_picture" - 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" - 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 57ebd0d7fc..54351693b0 100644 --- a/app/src/main/res/layout/activity_display_name.xml +++ b/app/src/main/res/layout/activity_display_name.xml @@ -32,6 +32,7 @@ <EditText style="@style/SmallSessionEditText" + android:contentDescription="@string/AccessibilityId_enter_display_name" android:id="@+id/displayNameEditText" android:layout_width="match_parent" android:layout_height="64dp" @@ -42,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 @@ -51,6 +54,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentFilled" + android:contentDescription="@string/AccessibilityId_continue" android:id="@+id/registerButton" android:layout_width="match_parent" android:layout_height="@dimen/medium_button_height" 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 8235a6eb9e..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,8 @@ 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"/> <EditText @@ -48,6 +50,8 @@ 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" /> <ImageView @@ -55,6 +59,8 @@ 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"/> </LinearLayout> @@ -72,6 +78,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" + android:contentDescription="@string/AccessibilityId_group_name" android:textColor="?android:textColorPrimary" android:textSize="@dimen/very_large_font_size" android:textStyle="bold" @@ -113,10 +120,10 @@ style="@style/Widget.Session.Button.Common.ProminentOutline" android:layout_width="wrap_content" android:layout_height="@dimen/small_button_height" - android:layout_marginTop="@dimen/small_spacing" + android:layout_marginVertical="@dimen/small_spacing" android:layout_marginEnd="@dimen/medium_spacing" android:layout_marginStart="@dimen/small_spacing" - android:layout_marginBottom="@dimen/small_spacing" + android:contentDescription="@string/AccessibilityId_add_members" android:paddingStart="@dimen/medium_spacing" android:paddingEnd="@dimen/medium_spacing" android:text="@string/activity_edit_closed_group_add_members" /> diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index 935848565c..124a44b374 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -27,20 +27,21 @@ android:layout_marginLeft="20dp" android:layout_marginRight="20dp"> - <include layout="@layout/view_profile_picture" + <org.thoughtcrime.securesms.components.ProfilePictureView android:id="@+id/profileButton" android:layout_width="@dimen/small_profile_picture_size" android:layout_height="@dimen/small_profile_picture_size" android:layout_alignParentLeft="true" android:layout_centerVertical="true" - android:layout_marginLeft="9dp" /> + android:layout_marginLeft="9dp" + android:contentDescription="@string/AccessibilityId_user_settings" /> <org.thoughtcrime.securesms.home.PathStatusView android:id="@+id/pathStatusView" - android:layout_alignBottom="@+id/profileButton" - android:layout_alignEnd="@+id/profileButton" android:layout_width="@dimen/path_status_view_size" - android:layout_height="@dimen/path_status_view_size"/> + android:layout_height="@dimen/path_status_view_size" + android:layout_alignEnd="@+id/profileButton" + android:layout_alignBottom="@+id/profileButton" /> <ImageView android:id="@+id/sessionHeaderImage" @@ -55,6 +56,8 @@ <RelativeLayout android:id="@+id/searchViewContainer" + android:background="?selectableItemBackgroundBorderless" + android:contentDescription="@string/AccessibilityId_search_icon" android:layout_width="@dimen/small_profile_picture_size" android:layout_height="@dimen/small_profile_picture_size" android:layout_alignParentRight="true" @@ -72,16 +75,17 @@ </RelativeLayout> <RelativeLayout - android:visibility="gone" android:id="@+id/search_toolbar" - android:layout_marginHorizontal="@dimen/medium_spacing" android:layout_width="match_parent" - android:layout_height="?actionBarSize"> + android:layout_height="?actionBarSize" + android:layout_marginHorizontal="@dimen/medium_spacing" + android:visibility="gone"> + <org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout - android:layout_centerVertical="true" android:id="@+id/globalSearchInputLayout" android:layout_width="match_parent" - android:layout_height="wrap_content"/> + android:layout_height="wrap_content" + android:layout_centerVertical="true" /> </RelativeLayout> <View @@ -91,19 +95,48 @@ android:elevation="1dp" /> <org.thoughtcrime.securesms.onboarding.SeedReminderView + tools:visibility="gone" android:id="@+id/seedReminderView" android:layout_width="match_parent" android:layout_height="wrap_content" /> + <FrameLayout + tools:visibility="visible" + android:visibility="gone" + android:id="@+id/configOutdatedView" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <TextView + android:layout_gravity="center" + android:textColor="?message_sent_text_color" + android:background="?colorAccent" + android:textSize="11sp" + android:paddingVertical="4dp" + android:paddingHorizontal="64dp" + android:gravity="center" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/activity_home_outdated_client_config" + /> + <ImageView + android:layout_margin="@dimen/small_spacing" + android:layout_gravity="center_vertical|right" + android:layout_width="12dp" + android:layout_height="12dp" + android:scaleType="centerInside" + android:src="@drawable/ic_x" + app:tint="@color/black" /> + </FrameLayout> + </LinearLayout> </androidx.appcompat.widget.Toolbar> <RelativeLayout - android:focusable="false" android:layout_width="match_parent" android:layout_height="match_parent" - android:clipChildren="false"> + android:clipChildren="false" + android:focusable="false"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView" @@ -116,14 +149,14 @@ tools:listitem="@layout/view_conversation" /> <androidx.recyclerview.widget.RecyclerView - android:visibility="gone" - android:scrollbars="vertical" android:id="@+id/globalSearchRecycler" android:layout_width="match_parent" + android:layout_height="match_parent" + android:scrollbars="vertical" + android:visibility="gone" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - tools:listitem="@layout/view_global_search_result" tools:itemCount="6" - android:layout_height="match_parent"/> + tools:listitem="@layout/view_global_search_result" /> <LinearLayout android:id="@+id/emptyStateContainer" @@ -154,6 +187,7 @@ <com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/newConversationButton" + android:contentDescription="@string/AccessibilityId_new_conversation_button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentBottom="true" diff --git a/app/src/main/res/layout/activity_join_public_chat.xml b/app/src/main/res/layout/activity_join_public_chat.xml index d437a230fc..e4d5fb433b 100644 --- a/app/src/main/res/layout/activity_join_public_chat.xml +++ b/app/src/main/res/layout/activity_join_public_chat.xml @@ -11,7 +11,6 @@ android:layout_height="match_parent" > <com.google.android.material.tabs.TabLayout - style="@style/Widget.Session.TabLayout" android:id="@+id/tabLayout" android:layout_width="match_parent" android:layout_height="@dimen/tab_bar_height" /> diff --git a/app/src/main/res/layout/activity_landing.xml b/app/src/main/res/layout/activity_landing.xml index f22cb15551..b7c3d1d6bd 100644 --- a/app/src/main/res/layout/activity_landing.xml +++ b/app/src/main/res/layout/activity_landing.xml @@ -32,6 +32,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentFilled" + android:contentDescription="@string/AccessibilityId_create_session_id" android:id="@+id/registerButton" android:layout_width="match_parent" android:layout_height="@dimen/medium_button_height" @@ -41,6 +42,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentOutline" + android:contentDescription="@string/AccessibilityId_restore_your_session" android:id="@+id/restoreButton" android:layout_width="match_parent" android:layout_height="@dimen/medium_button_height" @@ -50,13 +52,13 @@ android:text="@string/activity_landing_restore_button_title" /> <Button + style="@style/Widget.Session.Button.Common.Borderless" + android:contentDescription="@string/AccessibilityId_link_a_device" android:id="@+id/linkButton" 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:layout_marginHorizontal="@dimen/massive_spacing" android:gravity="center" - android:background="@color/transparent" android:textAllCaps="false" android:textSize="@dimen/medium_font_size" android:text="@string/activity_link_device_link_device" /> diff --git a/app/src/main/res/layout/activity_link_device.xml b/app/src/main/res/layout/activity_link_device.xml index 947d2c1c29..b267c08ac8 100644 --- a/app/src/main/res/layout/activity_link_device.xml +++ b/app/src/main/res/layout/activity_link_device.xml @@ -12,7 +12,6 @@ android:layout_height="match_parent" > <com.google.android.material.tabs.TabLayout - style="@style/Widget.Session.TabLayout" android:id="@+id/tabLayout" android:layout_width="match_parent" android:layout_height="@dimen/tab_bar_height" /> @@ -20,9 +19,9 @@ </androidx.viewpager.widget.ViewPager> <FrameLayout - android:animateLayoutChanges="true" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_height="match_parent" + android:animateLayoutChanges="true"> <RelativeLayout android:id="@+id/loader" @@ -35,8 +34,8 @@ style="@style/SpinKitView.Large.ThreeBounce" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="8dp" android:layout_centerInParent="true" + android:layout_marginTop="8dp" app:SpinKit_Color="@android:color/white" /> </RelativeLayout> diff --git a/app/src/main/res/layout/activity_message_detail.xml b/app/src/main/res/layout/activity_message_detail.xml index c5a7f12bf1..49c1af54e3 100644 --- a/app/src/main/res/layout/activity_message_detail.xml +++ b/app/src/main/res/layout/activity_message_detail.xml @@ -69,6 +69,7 @@ </TableRow> <TableRow + android:id="@+id/error_container" android:layout_width="wrap_content" android:layout_height="wrap_content" android:paddingHorizontal="@dimen/small_spacing" @@ -94,6 +95,7 @@ </TableLayout> <LinearLayout + android:id="@+id/resend_container" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="@dimen/very_large_spacing" diff --git a/app/src/main/res/layout/activity_message_requests.xml b/app/src/main/res/layout/activity_message_requests.xml index 4cb996cabf..083b5b9c44 100644 --- a/app/src/main/res/layout/activity_message_requests.xml +++ b/app/src/main/res/layout/activity_message_requests.xml @@ -39,6 +39,7 @@ android:visibility="gone"> <TextView + android:contentDescription="@string/AccessibilityId_empty_message_request_folder" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/message_request_empty_state_message" @@ -48,6 +49,7 @@ </LinearLayout> <Button + android:contentDescription="@string/AccessibilityId_clear_all_message_requests" android:id="@+id/clearAllMessageRequestsButton" style="@style/Widget.Session.Button.Common.DestructiveOutline" android:layout_width="196dp" diff --git a/app/src/main/res/layout/activity_pn_mode.xml b/app/src/main/res/layout/activity_pn_mode.xml index 284ca649b8..0cdbeb992a 100644 --- a/app/src/main/res/layout/activity_pn_mode.xml +++ b/app/src/main/res/layout/activity_pn_mode.xml @@ -19,6 +19,7 @@ android:textSize="@dimen/large_font_size" android:textStyle="bold" android:textColor="?android:textColorPrimary" + android:contentDescription="@string/AccessibilityId_message_notifications" android:text="@string/activity_pn_mode_message_notifications" /> <TextView @@ -32,6 +33,7 @@ android:text="@string/activity_pn_mode_explanation" /> <org.thoughtcrime.securesms.util.PNModeView + android:contentDescription="@string/AccessibilityId_fast_mode_notifications_option" android:id="@+id/fcmOptionView" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -40,7 +42,7 @@ android:layout_marginRight="@dimen/very_large_spacing" android:padding="12dp" android:orientation="vertical" - android:background="@drawable/pn_option_background"> + android:background="@color/pn_option_border"> <TextView android:layout_width="match_parent" @@ -70,6 +72,7 @@ </org.thoughtcrime.securesms.util.PNModeView> <org.thoughtcrime.securesms.util.PNModeView + android:contentDescription="@string/AccessibilityId_slow_mode_notifications_option" android:id="@+id/backgroundPollingOptionView" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -100,6 +103,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentFilled" + android:contentDescription="@string/AccessibilityId_continue_message_notifications" android:id="@+id/registerButton" android:layout_width="match_parent" android:layout_height="@dimen/medium_button_height" diff --git a/app/src/main/res/layout/activity_qr_code.xml b/app/src/main/res/layout/activity_qr_code.xml index 6a1229648e..58c7e40c82 100644 --- a/app/src/main/res/layout/activity_qr_code.xml +++ b/app/src/main/res/layout/activity_qr_code.xml @@ -6,7 +6,6 @@ android:layout_height="match_parent" > <com.google.android.material.tabs.TabLayout - style="@style/Widget.Session.TabLayout" android:id="@+id/tabLayout" android:layout_width="match_parent" android:layout_height="@dimen/tab_bar_height" /> 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 e3585a0a50..0000000000 --- a/app/src/main/res/layout/activity_recovery_phrase_restore.xml +++ /dev/null @@ -1,76 +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 - style="@style/SmallSessionEditText" - android:id="@+id/mnemonicEditText" - 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: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: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_register.xml b/app/src/main/res/layout/activity_register.xml index 0d6f6a5c81..e2434e7c77 100644 --- a/app/src/main/res/layout/activity_register.xml +++ b/app/src/main/res/layout/activity_register.xml @@ -33,6 +33,7 @@ <TextView style="@style/SessionIDTextView" + android:contentDescription="@string/AccessibilityId_session_id" android:id="@+id/publicKeyTextView" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -49,6 +50,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentFilled" + android:contentDescription="@string/AccessibilityId_continue" android:id="@+id/registerButton" android:layout_width="match_parent" android:layout_height="@dimen/medium_button_height" @@ -58,6 +60,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentOutline" + android:contentDescription="@string/AccessibilityId_copy_session_id" android:id="@+id/copyButton" android:layout_width="match_parent" android:layout_height="@dimen/medium_button_height" diff --git a/app/src/main/res/layout/activity_seed.xml b/app/src/main/res/layout/activity_seed.xml index 221cef8044..4587bc01df 100644 --- a/app/src/main/res/layout/activity_seed.xml +++ b/app/src/main/res/layout/activity_seed.xml @@ -38,6 +38,7 @@ <TextView style="@style/SessionIDTextView" + android:contentDescription="@string/AccessibilityId_recovery_phrase" android:id="@+id/seedTextView" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -49,16 +50,16 @@ android:textAlignment="center" tools:text="nautical novelty populate onion awkward bent etiquette plant submarine itches vipers september axis maximum populate" /> - <TextView + <Button android:id="@+id/revealButton" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="6dp" + android:layout_width="196dp" + android:layout_height="@dimen/onboarding_button_bottom_offset" + android:layout_marginHorizontal="@dimen/massive_spacing" android:textAlignment="center" android:textSize="14sp" - android:textColor="?android:textColorPrimary" android:alpha="0.6" android:visibility="gone" + style="@style/Widget.Session.Button.Common.Borderless" android:text="@string/activity_seed_reveal_button_title" /> <View @@ -68,6 +69,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentOutline" + android:contentDescription="@string/AccessibilityId_copy_recovery_phrase" android:id="@+id/copyButton" android:layout_width="196dp" android:layout_height="@dimen/medium_button_height" diff --git a/app/src/main/res/layout/activity_select_contacts.xml b/app/src/main/res/layout/activity_select_contacts.xml index cc695bdc7f..611ceef3dc 100644 --- a/app/src/main/res/layout/activity_select_contacts.xml +++ b/app/src/main/res/layout/activity_select_contacts.xml @@ -1,26 +1,19 @@ <?xml version="1.0" encoding="utf-8"?> -<RelativeLayout +<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> - <LinearLayout - android:id="@+id/mainContentContainer" + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/recyclerView" android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical"> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/recyclerView" - android:layout_width="match_parent" - android:layout_height="match_parent" /> - - </LinearLayout> + android:layout_height="match_parent" /> <LinearLayout android:id="@+id/emptyStateContainer" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_gravity="center" android:gravity="center_horizontal" android:orientation="vertical" android:layout_centerInParent="true"> @@ -35,4 +28,4 @@ </LinearLayout> -</RelativeLayout> \ No newline at end of file +</androidx.coordinatorlayout.widget.CoordinatorLayout> \ 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 479ca7f8ea..d84f183b5c 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -21,14 +21,16 @@ android:orientation="vertical" android:gravity="center_horizontal"> - <include layout="@layout/view_profile_picture" + <org.thoughtcrime.securesms.components.ProfilePictureView android:id="@+id/profilePictureView" android:layout_width="@dimen/large_profile_picture_size" android:layout_height="@dimen/large_profile_picture_size" - android:layout_marginTop="@dimen/medium_spacing" /> + android:layout_marginTop="@dimen/medium_spacing" + android:contentDescription="@string/AccessibilityId_profile_picture" /> <RelativeLayout android:id="@+id/ctnGroupNameSection" + android:contentDescription="@string/AccessibilityId_username" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginLeft="@dimen/large_spacing" @@ -36,29 +38,36 @@ android:layout_marginRight="@dimen/large_spacing"> <EditText - style="@style/SessionEditText" android:id="@+id/displayNameEditText" + style="@style/SessionEditText" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" - android:textAlignment="center" + android:contentDescription="@string/AccessibilityId_username" 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" android:layout_width="wrap_content" 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> - <org.thoughtcrime.securesms.components.LabeledSeparatorView + <include layout="@layout/view_separator" android:id="@+id/separatorView" android:layout_width="match_parent" android:layout_height="32dp" @@ -77,6 +86,7 @@ android:textColor="?android:textColorPrimary" android:fontFamily="@font/space_mono_regular" android:textAlignment="center" + android:contentDescription="@string/AccessibilityId_session_id" tools:text="05987d601943c267879be41830888066c6a024cbdc9a548d06813924bf3372ea78" /> <LinearLayout @@ -117,6 +127,7 @@ <!-- Path --> <RelativeLayout android:id="@+id/pathButton" + android:background="?selectableItemBackground" android:orientation="horizontal" android:layout_width="match_parent" android:paddingHorizontal="@dimen/large_spacing" @@ -153,6 +164,7 @@ <RelativeLayout android:id="@+id/privacyButton" + android:background="?selectableItemBackground" android:paddingHorizontal="@dimen/large_spacing" android:layout_width="match_parent" android:layout_height="@dimen/setting_button_height"> @@ -186,9 +198,11 @@ <RelativeLayout android:id="@+id/notificationsButton" + android:background="?selectableItemBackground" android:paddingHorizontal="@dimen/large_spacing" android:layout_width="match_parent" - android:layout_height="@dimen/setting_button_height"> + android:layout_height="@dimen/setting_button_height" + android:contentDescription="@string/AccessibilityId_notifications"> <ImageView android:id="@+id/notificationsContainer" android:layout_width="@dimen/small_profile_picture_size" @@ -208,7 +222,8 @@ android:textStyle="bold" android:gravity="center" android:layout_toEndOf="@+id/notificationsContainer" - android:text="@string/activity_settings_notifications_button_title" /> + android:text="@string/activity_settings_notifications_button_title" + /> </RelativeLayout> @@ -220,9 +235,12 @@ <RelativeLayout android:id="@+id/chatsButton" + android:background="?selectableItemBackground" android:paddingHorizontal="@dimen/large_spacing" android:layout_width="match_parent" - android:layout_height="@dimen/setting_button_height"> + android:layout_height="@dimen/setting_button_height" + android:contentDescription="@string/AccessibilityId_conversations"> + <ImageView android:id="@+id/chatsContainer" android:layout_width="@dimen/small_profile_picture_size" @@ -253,9 +271,11 @@ <RelativeLayout android:id="@+id/messageRequestsButton" + android:background="?selectableItemBackground" android:paddingHorizontal="@dimen/large_spacing" android:layout_width="match_parent" - android:layout_height="@dimen/setting_button_height"> + android:layout_height="@dimen/setting_button_height" + android:contentDescription="@string/AccessibilityId_message_requests"> <ImageView android:id="@+id/messageRequestsContainer" android:layout_width="@dimen/small_profile_picture_size" @@ -286,9 +306,11 @@ <RelativeLayout android:id="@+id/appearanceButton" + android:background="?selectableItemBackground" android:paddingHorizontal="@dimen/large_spacing" android:layout_width="match_parent" - android:layout_height="@dimen/setting_button_height"> + android:layout_height="@dimen/setting_button_height" + android:contentDescription="@string/AccessibilityId_appearance"> <ImageView android:id="@+id/appearanceContainer" android:layout_width="@dimen/small_profile_picture_size" @@ -319,9 +341,11 @@ <RelativeLayout android:id="@+id/inviteFriendButton" + android:background="?selectableItemBackground" android:paddingHorizontal="@dimen/large_spacing" android:layout_width="match_parent" - android:layout_height="@dimen/setting_button_height"> + android:layout_height="@dimen/setting_button_height" + android:contentDescription="@string/AccessibilityId_invite_friend"> <ImageView android:id="@+id/inviteFriendContainer" android:layout_width="@dimen/small_profile_picture_size" @@ -352,9 +376,11 @@ <RelativeLayout android:id="@+id/seedButton" + android:background="?selectableItemBackground" android:paddingHorizontal="@dimen/large_spacing" android:layout_width="match_parent" - android:layout_height="@dimen/setting_button_height"> + android:layout_height="@dimen/setting_button_height" + android:contentDescription="@string/AccessibilityId_recovery_phrase"> <ImageView android:id="@+id/seedContainer" android:layout_width="@dimen/small_profile_picture_size" @@ -385,9 +411,11 @@ <RelativeLayout android:id="@+id/helpButton" + android:background="?selectableItemBackground" android:paddingHorizontal="@dimen/large_spacing" android:layout_width="match_parent" - android:layout_height="@dimen/setting_button_height"> + android:layout_height="@dimen/setting_button_height" + android:contentDescription="@string/AccessibilityId_help"> <ImageView android:id="@+id/helpContainer" android:layout_width="@dimen/small_profile_picture_size" @@ -418,9 +446,11 @@ <RelativeLayout android:id="@+id/clearAllDataButton" + android:background="?selectableItemBackground" android:paddingHorizontal="@dimen/large_spacing" android:layout_width="match_parent" - android:layout_height="@dimen/setting_button_height"> + android:layout_height="@dimen/setting_button_height" + android:contentDescription="@string/AccessibilityId_clear_data"> <ImageView android:id="@+id/clearContainer" android:layout_width="@dimen/small_profile_picture_size" diff --git a/app/src/main/res/layout/album_thumbnail_1.xml b/app/src/main/res/layout/album_thumbnail_1.xml index cf0f5d4892..cee81ba3e3 100644 --- a/app/src/main/res/layout/album_thumbnail_1.xml +++ b/app/src/main/res/layout/album_thumbnail_1.xml @@ -6,7 +6,7 @@ android:layout_width="@dimen/media_bubble_default_dimens" android:layout_height="@dimen/media_bubble_default_dimens"> - <org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView + <include layout="@layout/thumbnail_view" android:id="@+id/album_cell_1" android:layout_width="match_parent" android:layout_height="match_parent" diff --git a/app/src/main/res/layout/album_thumbnail_2.xml b/app/src/main/res/layout/album_thumbnail_2.xml index 3bb4a6a367..52375d025c 100644 --- a/app/src/main/res/layout/album_thumbnail_2.xml +++ b/app/src/main/res/layout/album_thumbnail_2.xml @@ -7,13 +7,13 @@ android:layout_width="@dimen/album_total_width" android:layout_height="@dimen/album_2_total_height"> - <org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView + <include layout="@layout/thumbnail_view" android:id="@+id/album_cell_1" android:layout_width="@dimen/album_2_cell_width" android:layout_height="@dimen/album_2_total_height" app:thumbnail_radius="0dp"/> - <org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView + <include layout="@layout/thumbnail_view" android:id="@+id/album_cell_2" android:layout_width="@dimen/album_2_cell_width" android:layout_height="@dimen/album_2_total_height" diff --git a/app/src/main/res/layout/album_thumbnail_3.xml b/app/src/main/res/layout/album_thumbnail_3.xml index 319f78ee78..b408ffd2bc 100644 --- a/app/src/main/res/layout/album_thumbnail_3.xml +++ b/app/src/main/res/layout/album_thumbnail_3.xml @@ -6,13 +6,13 @@ android:layout_width="@dimen/album_total_width" android:layout_height="@dimen/album_3_total_height"> - <org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView + <include layout="@layout/thumbnail_view" android:id="@+id/album_cell_1" android:layout_width="@dimen/album_3_cell_width_big" android:layout_height="@dimen/album_3_total_height" app:thumbnail_radius="0dp"/> - <org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView + <include layout="@layout/thumbnail_view" android:id="@+id/album_cell_2" android:layout_width="@dimen/album_3_cell_size_small" android:layout_height="@dimen/album_3_cell_size_small" @@ -25,7 +25,7 @@ android:layout_height="@dimen/album_5_cell_size_small" android:layout_gravity="end|bottom"> - <org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView + <include layout="@layout/thumbnail_view" android:id="@+id/album_cell_3" android:layout_height="match_parent" android:layout_width="match_parent" diff --git a/app/src/main/res/layout/album_thumbnail_view.xml b/app/src/main/res/layout/album_thumbnail_view.xml index 9e5cc0c536..b3f39d1d55 100644 --- a/app/src/main/res/layout/album_thumbnail_view.xml +++ b/app/src/main/res/layout/album_thumbnail_view.xml @@ -1,5 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> -<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" +<org.thoughtcrime.securesms.conversation.v2.components.AlbumThumbnailView + xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> @@ -20,4 +21,4 @@ android:layout_gravity="center" android:layout="@layout/transfer_controls_stub" /> -</RelativeLayout> \ No newline at end of file +</org.thoughtcrime.securesms.conversation.v2.components.AlbumThumbnailView> \ No newline at end of file diff --git a/app/src/main/res/layout/blocked_contact_layout.xml b/app/src/main/res/layout/blocked_contact_layout.xml index b454a0776e..96b343c4b4 100644 --- a/app/src/main/res/layout/blocked_contact_layout.xml +++ b/app/src/main/res/layout/blocked_contact_layout.xml @@ -9,13 +9,14 @@ android:gravity="center_vertical" android:background="?selectableItemBackground" android:id="@+id/backgroundContainer"> - <include layout="@layout/view_profile_picture" + <org.thoughtcrime.securesms.components.ProfilePictureView android:id="@+id/profilePictureView" android:layout_height="@dimen/small_profile_picture_size" android:layout_width="@dimen/small_profile_picture_size" /> <TextView + android:contentDescription="@string/AccessibilityId_contact" android:textSize="@dimen/text_size" android:textStyle="bold" android:layout_width="0dp" diff --git a/app/src/main/res/layout/blocked_contacts_preference.xml b/app/src/main/res/layout/blocked_contacts_preference.xml index bc49ce3fae..673d5316a1 100644 --- a/app/src/main/res/layout/blocked_contacts_preference.xml +++ b/app/src/main/res/layout/blocked_contacts_preference.xml @@ -1,16 +1,15 @@ <?xml version="1.0" encoding="utf-8"?> -<org.thoughtcrime.securesms.preferences.BlockedContactsLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" +<Button + xmlns:android="http://schemas.android.com/apk/res/android" + android:contentDescription="@string/AccessibilityId_blocked_contacts" + style="@style/Widget.Session.Button.Common.Borderless" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:paddingHorizontal="@dimen/medium_spacing" android:layout_marginHorizontal="@dimen/large_spacing" android:layout_marginVertical="@dimen/medium_spacing" - android:layout_height="wrap_content"> - <TextView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:padding="@dimen/medium_spacing" - android:text="@string/blocked_contacts_title" - android:textColor="@color/destructive" - android:textSize="16sp" - android:textStyle="bold" - /> -</org.thoughtcrime.securesms.preferences.BlockedContactsLayout> \ No newline at end of file + android:text="@string/blocked_contacts_title" + android:textColor="@color/destructive" + android:textSize="16sp" + android:textStyle="bold" +/> diff --git a/app/src/main/res/layout/contact_selection_list_fragment.xml b/app/src/main/res/layout/contact_selection_list_fragment.xml index 7a297bab2c..48221f632f 100644 --- a/app/src/main/res/layout/contact_selection_list_fragment.xml +++ b/app/src/main/res/layout/contact_selection_list_fragment.xml @@ -18,35 +18,13 @@ </LinearLayout> - <LinearLayout - android:id="@+id/mainContentContainer" + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/recyclerView" + android:layout_gravity="center" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="vertical"> - - <androidx.swiperefreshlayout.widget.SwipeRefreshLayout - android:id="@+id/swipeRefreshLayout" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/recyclerView" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:clipToPadding="false" - android:scrollbars="vertical" - tools:listitem="@layout/view_user"/> - - <TextView - android:id="@+id/loadingTextView" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:gravity="center" - android:text="@string/contact_selection_group_activity__finding_contacts" - android:textSize="@dimen/large_font_size" /> - - </androidx.swiperefreshlayout.widget.SwipeRefreshLayout> - - </LinearLayout> + android:clipToPadding="false" + android:scrollbars="vertical" + tools:listitem="@layout/view_user"/> </FrameLayout> 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 ab16a09304..0000000000 --- a/app/src/main/res/layout/conversation_item_footer.xml +++ /dev/null @@ -1,56 +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: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_blocked.xml b/app/src/main/res/layout/dialog_blocked.xml deleted file mode 100644 index bf71c1ffa3..0000000000 --- a/app/src/main/res/layout/dialog_blocked.xml +++ /dev/null @@ -1,56 +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="match_parent" - android:gravity="center_horizontal" - android:orientation="vertical" - android:elevation="4dp" - android:padding="@dimen/medium_spacing"> - - <TextView - android:id="@+id/blockedTitleTextView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/dialog_blocked_title" - android:textColor="?android:textColorPrimary" - android:textStyle="bold" - android:textSize="@dimen/large_font_size" /> - - <TextView - android:id="@+id/blockedExplanationTextView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:text="@string/dialog_blocked_explanation" - android:paddingHorizontal="@dimen/medium_spacing" - android:textColor="?android:textColorPrimary" - android:textSize="@dimen/small_font_size" - android:textAlignment="center" /> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:orientation="horizontal"> - - <Button - style="@style/Widget.Session.Button.Dialog.Unimportant" - android:id="@+id/cancelButton" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - android:text="@string/cancel" /> - - <Button - style="@style/Widget.Session.Button.Dialog.Unimportant" - android:id="@+id/unblockButton" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - android:layout_marginStart="@dimen/medium_spacing" - android:text="@string/ConversationActivity_unblock" /> - - </LinearLayout> - -</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_change_avatar.xml b/app/src/main/res/layout/dialog_change_avatar.xml new file mode 100644 index 0000000000..98ba77ac0c --- /dev/null +++ b/app/src/main/res/layout/dialog_change_avatar.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout + android:orientation="vertical" + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_height="wrap_content" + android:layout_width="match_parent"> + + <FrameLayout + android:layout_gravity="center" + android:id="@+id/ic_pictures" + android:background="@drawable/circle_tintable" + android:backgroundTint="@color/classic_dark_3" + android:layout_marginTop="15dp" + android:layout_marginBottom="15dp" + android:layout_width="@dimen/large_profile_picture_size" + android:layout_height="@dimen/large_profile_picture_size"> + + <ImageView + android:layout_gravity="center" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:background="@color/transparent" + android:src="@drawable/ic_pictures"/> + + <!-- TODO: Add this back when we build the custom modal which allows tapping on the image to select a replacement--> +<!-- <LinearLayout--> +<!-- android:layout_gravity="bottom|end"--> +<!-- android:gravity="center"--> +<!-- android:background="@drawable/circle_tintable"--> +<!-- android:backgroundTint="?attr/accentColor"--> +<!-- android:paddingTop="1dp"--> +<!-- android:paddingLeft="1dp"--> +<!-- android:layout_width="24dp"--> +<!-- android:layout_height="24dp"--> +<!-- tools:backgroundTint="@color/accent_green">--> +<!-- <View--> +<!-- android:background="@drawable/ic_plus"--> +<!-- android:backgroundTint="@color/black"--> +<!-- android:layout_width="12dp"--> +<!-- android:layout_height="12dp"--> +<!-- />--> +<!-- </LinearLayout>--> + + + </FrameLayout> + + <org.thoughtcrime.securesms.components.ProfilePictureView + android:layout_margin="30dp" + android:id="@+id/profile_picture_view" + android:layout_gravity="center" + android:layout_width="@dimen/large_profile_picture_size" + android:layout_height="@dimen/large_profile_picture_size" + android:layout_marginTop="@dimen/medium_spacing" + android:contentDescription="@string/AccessibilityId_profile_picture" + tools:visibility="gone"/> + +</FrameLayout> 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_download.xml b/app/src/main/res/layout/dialog_download.xml deleted file mode 100644 index 78d82db516..0000000000 --- a/app/src/main/res/layout/dialog_download.xml +++ /dev/null @@ -1,56 +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="match_parent" - android:gravity="center_horizontal" - android:orientation="vertical" - android:elevation="4dp" - android:padding="@dimen/medium_spacing"> - - <TextView - android:id="@+id/downloadTitleTextView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/dialog_download_title" - android:textColor="?android:textColorPrimary" - android:textStyle="bold" - android:textSize="@dimen/large_font_size" /> - - <TextView - android:id="@+id/downloadExplanationTextView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:text="@string/dialog_download_explanation" - android:paddingHorizontal="@dimen/medium_spacing" - android:textColor="?android:textColorPrimary" - android:textSize="@dimen/small_font_size" - android:textAlignment="center" /> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:orientation="horizontal"> - - <Button - style="@style/Widget.Session.Button.Dialog.Unimportant" - android:id="@+id/cancelButton" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - android:text="@string/cancel" /> - - <Button - style="@style/Widget.Session.Button.Dialog.Unimportant" - android:id="@+id/downloadButton" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - android:layout_marginStart="@dimen/medium_spacing" - android:text="@string/dialog_download_button_title" /> - - </LinearLayout> - -</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_join_open_group.xml b/app/src/main/res/layout/dialog_join_open_group.xml deleted file mode 100644 index 81289b6dd4..0000000000 --- a/app/src/main/res/layout/dialog_join_open_group.xml +++ /dev/null @@ -1,56 +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="match_parent" - android:gravity="center_horizontal" - android:orientation="vertical" - android:elevation="4dp" - android:padding="@dimen/medium_spacing"> - - <TextView - android:id="@+id/joinOpenGroupTitleTextView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/dialog_join_open_group_title" - android:textColor="?android:textColorPrimary" - android:textStyle="bold" - android:textSize="@dimen/large_font_size" /> - - <TextView - android:id="@+id/joinOpenGroupExplanationTextView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:text="@string/dialog_join_open_group_explanation" - android:paddingHorizontal="@dimen/medium_spacing" - android:textColor="?android:textColorPrimary" - android:textSize="@dimen/small_font_size" - android:textAlignment="center" /> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:orientation="horizontal"> - - <Button - style="@style/Widget.Session.Button.Dialog.Unimportant" - android:id="@+id/cancelButton" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - android:text="@string/cancel" /> - - <Button - style="@style/Widget.Session.Button.Dialog.Unimportant" - android:id="@+id/joinButton" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - android:layout_marginStart="@dimen/medium_spacing" - android:text="@string/open_group_invitation_view__join_accessibility_description" /> - - </LinearLayout> - -</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_link_preview.xml b/app/src/main/res/layout/dialog_link_preview.xml deleted file mode 100644 index f194ad0f2e..0000000000 --- a/app/src/main/res/layout/dialog_link_preview.xml +++ /dev/null @@ -1,56 +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="match_parent" - android:gravity="center_horizontal" - android:orientation="vertical" - android:elevation="4dp" - android:padding="@dimen/medium_spacing"> - - <TextView - android:id="@+id/linkPreviewDialogTitleTextView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/dialog_link_preview_title" - android:textColor="?android:textColorPrimary" - android:textStyle="bold" - android:textSize="@dimen/large_font_size" /> - - <TextView - android:id="@+id/linkPreviewDialogExplanationTextView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:text="@string/dialog_link_preview_explanation" - android:paddingHorizontal="@dimen/medium_spacing" - android:textColor="?android:textColorPrimary" - android:textSize="@dimen/small_font_size" - android:textAlignment="center" /> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:orientation="horizontal"> - - <Button - style="@style/Widget.Session.Button.Dialog.Unimportant" - android:id="@+id/cancelButton" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - android:text="@string/cancel" /> - - <Button - style="@style/Widget.Session.Button.Dialog.Unimportant" - android:id="@+id/enableLinkPreviewsButton" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - android:layout_marginStart="@dimen/medium_spacing" - android:text="@string/dialog_link_preview_enable_button_title" /> - - </LinearLayout> - -</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_list_preference.xml b/app/src/main/res/layout/dialog_list_preference.xml index cf7be2d2c0..1dfe219e85 100644 --- a/app/src/main/res/layout/dialog_list_preference.xml +++ b/app/src/main/res/layout/dialog_list_preference.xml @@ -3,8 +3,7 @@ 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:background="?dialog_background_color"> + android:layout_height="match_parent"> <TextView android:id="@+id/titleTextView" @@ -21,11 +20,10 @@ <ImageView android:id="@+id/closeButton" + android:background="?selectableItemBackgroundBorderless" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="@dimen/medium_spacing" - android:clickable="true" - android:focusable="true" android:src="@drawable/ic_baseline_close_24" app:layout_constraintBottom_toBottomOf="@id/titleTextView" app:layout_constraintEnd_toEndOf="parent" @@ -36,7 +34,6 @@ android:id="@+id/messageTextView" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="?dialog_background_color" android:drawablePadding="@dimen/large_spacing" android:gravity="center_horizontal|center_vertical" android:paddingHorizontal="@dimen/large_spacing" diff --git a/app/src/main/res/layout/dialog_open_url.xml b/app/src/main/res/layout/dialog_open_url.xml deleted file mode 100644 index af5b09b9c7..0000000000 --- a/app/src/main/res/layout/dialog_open_url.xml +++ /dev/null @@ -1,56 +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="match_parent" - android:gravity="center_horizontal" - android:orientation="vertical" - android:elevation="4dp" - android:padding="@dimen/medium_spacing"> - - <TextView - android:id="@+id/openURLTitleTextView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/dialog_open_url_title" - android:textColor="?android:textColorPrimary" - android:textStyle="bold" - android:textSize="@dimen/large_font_size" /> - - <TextView - android:id="@+id/openURLExplanationTextView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:text="@string/dialog_open_url_explanation" - android:paddingHorizontal="@dimen/medium_spacing" - android:textColor="?android:textColorPrimary" - android:textSize="@dimen/small_font_size" - android:textAlignment="center" /> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:orientation="horizontal"> - - <Button - style="@style/Widget.Session.Button.Dialog.Unimportant" - android:id="@+id/cancelButton" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - android:text="@string/cancel" /> - - <Button - style="@style/Widget.Session.Button.Dialog.Unimportant" - android:id="@+id/openURLButton" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - android:layout_marginStart="@dimen/medium_spacing" - android:text="@string/open" /> - - </LinearLayout> - -</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_seed.xml b/app/src/main/res/layout/dialog_seed.xml deleted file mode 100644 index 6e1042698b..0000000000 --- a/app/src/main/res/layout/dialog_seed.xml +++ /dev/null @@ -1,66 +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="match_parent" - xmlns:tools="http://schemas.android.com/tools" - android:gravity="center_horizontal" - android:orientation="vertical" - android:padding="@dimen/medium_spacing"> - - <TextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/dialog_seed_title" - android:textColor="?android:textColorPrimary" - android:textStyle="bold" - android:textAlignment="center" - android:textSize="@dimen/medium_font_size" /> - - <TextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:text="@string/dialog_seed_explanation" - android:textColor="?android:textColorPrimary" - android:textSize="@dimen/medium_font_size" - android:textAlignment="center" - android:alpha="0.6" /> - - <TextView - style="@style/SessionIDTextView" - android:id="@+id/seedTextView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:padding="@dimen/small_spacing" - tools:text="habitat kiwi amply iceberg dog nerves spiderman soft match partial awakened maximum degrees" - android:textColor="?android:textColorPrimary" - android:textSize="@dimen/small_font_size" - android:textAlignment="center" /> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:orientation="horizontal"> - - <Button - style="@style/Widget.Session.Button.Dialog.UnimportantText" - android:id="@+id/copyButton" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - android:layout_marginStart="@dimen/medium_spacing" - android:text="@string/copy" /> - - <Button - style="@style/Widget.Session.Button.Dialog.UnimportantText" - android:id="@+id/closeButton" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - android:text="@string/close" /> - - </LinearLayout> - -</LinearLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_send_seed.xml b/app/src/main/res/layout/dialog_send_seed.xml index 5bec0be4dd..725c9c4d83 100644 --- a/app/src/main/res/layout/dialog_send_seed.xml +++ b/app/src/main/res/layout/dialog_send_seed.xml @@ -35,18 +35,18 @@ android:orientation="horizontal"> <Button - style="@style/Widget.Session.Button.Dialog.Unimportant" + 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" /> <Button - style="@style/Widget.Session.Button.Dialog.Destructive" + 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/dialog_share_logs.xml b/app/src/main/res/layout/dialog_share_logs.xml deleted file mode 100644 index 5d1a94f686..0000000000 --- a/app/src/main/res/layout/dialog_share_logs.xml +++ /dev/null @@ -1,63 +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="match_parent" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:gravity="center_horizontal" - android:orientation="vertical" - android:elevation="4dp" - android:padding="@dimen/medium_spacing"> - - <TextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/dialog_share_logs_title" - android:textColor="?android:textColorPrimary" - android:textStyle="bold" - android:textSize="@dimen/medium_font_size" /> - - <TextView - android:id="@+id/dialogDescriptionText" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:text="@string/dialog_share_logs_explanation" - android:textColor="?android:textColorPrimary" - android:textSize="@dimen/small_font_size" - android:textAlignment="center" /> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/large_spacing" - android:orientation="horizontal"> - - <Button - style="@style/Widget.Session.Button.Dialog.Unimportant" - android:id="@+id/cancelButton" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - android:text="@string/cancel" /> - - <Button - style="@style/Widget.Session.Button.Dialog.Unimportant" - android:id="@+id/shareButton" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - android:layout_marginStart="@dimen/medium_spacing" - android:text="@string/share" /> - - <com.github.ybq.android.spinkit.SpinKitView - style="@style/SpinKitView.Small.ThreeBounce" - android:id="@+id/progressBar" - android:layout_width="0dp" - android:layout_height="@dimen/small_button_height" - android:layout_weight="1" - app:SpinKit_Color="?colorAccent" - android:visibility="gone" /> - - </LinearLayout> - -</LinearLayout> \ No newline at end of file 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 03b3026611..0000000000 --- a/app/src/main/res/layout/expiration_dialog.xml +++ /dev/null @@ -1,36 +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: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 683648154b..56f6bc07df 100644 --- a/app/src/main/res/layout/export_logs_widget.xml +++ b/app/src/main/res/layout/export_logs_widget.xml @@ -1,17 +1,17 @@ <?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" - android:background="@drawable/rounded_rectangle" - android:backgroundTint="?colorDividerBackground" - android:textColor="?android:textColorPrimary" + style="@style/Widget.Session.Button.Common.Filled" android:textStyle="bold" android:text="@string/activity_help_settings__export_logs" 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_call_bottom_sheet.xml b/app/src/main/res/layout/fragment_call_bottom_sheet.xml index 7b79d855f9..6ec1c76136 100644 --- a/app/src/main/res/layout/fragment_call_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_call_bottom_sheet.xml @@ -13,7 +13,7 @@ app:behavior_hideable="true" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> - <include layout="@layout/view_profile_picture" + <org.thoughtcrime.securesms.components.ProfilePictureView android:id="@+id/profilePictureView" android:layout_width="@dimen/large_profile_picture_size" android:layout_height="@dimen/large_profile_picture_size" diff --git a/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml b/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml index 81f62c4a73..b7df77ec71 100644 --- a/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_conversation_bottom_sheet.xml @@ -12,13 +12,31 @@ <TextView android:id="@+id/detailsTextView" style="@style/BottomSheetActionItem" + android:contentDescription="@string/AccessibilityId_details" android:drawableStart="@drawable/ic_info_outline_white_24dp" android:drawableTint="?attr/colorControlNormal" android:text="@string/details" /> + <TextView + android:id="@+id/copyConversationId" + style="@style/BottomSheetActionItem" + android:drawableStart="@drawable/ic_content_copy_white_24dp" + android:drawableTint="?attr/colorControlNormal" + android:visibility="gone" + android:text="@string/activity_conversation_menu_copy_session_id" /> + + <TextView + android:id="@+id/copyCommunityUrl" + style="@style/BottomSheetActionItem" + android:drawableStart="@drawable/ic_content_copy_white_24dp" + android:drawableTint="?attr/colorControlNormal" + android:visibility="gone" + android:text="@string/ConversationActivity_copy_open_group_url" /> + <TextView android:id="@+id/pinTextView" style="@style/BottomSheetActionItem" + android:contentDescription="@string/AccessibilityId_pin" android:drawableStart="?attr/menu_pin_icon" android:text="@string/conversation_pin" android:visibility="gone" @@ -35,6 +53,7 @@ <TextView android:id="@+id/blockTextView" style="@style/BottomSheetActionItem" + android:contentDescription="@string/AccessibilityId_block" android:drawableStart="?attr/menu_block_icon" android:text="@string/RecipientPreferenceActivity_block" android:visibility="gone" @@ -51,6 +70,7 @@ <TextView android:id="@+id/muteNotificationsTextView" style="@style/BottomSheetActionItem" + android:contentDescription="@string/AccessibilityId_mute_notifications" android:drawableStart="@drawable/ic_outline_notifications_off_24" android:text="@string/MuteDialog_mute_notifications" tools:visibility="visible" @@ -86,6 +106,7 @@ <TextView android:id="@+id/deleteTextView" style="@style/BottomSheetActionItem" + android:contentDescription="@string/AccessibilityId_delete" android:drawableStart="?attr/menu_trash_icon" android:text="@string/delete" /> diff --git a/app/src/main/res/layout/fragment_create_group.xml b/app/src/main/res/layout/fragment_create_group.xml index 2c7110f70e..1e901c753c 100644 --- a/app/src/main/res/layout/fragment_create_group.xml +++ b/app/src/main/res/layout/fragment_create_group.xml @@ -22,9 +22,11 @@ <ImageView android:id="@+id/backButton" + android:background="?selectableItemBackgroundBorderless" + android:padding="@dimen/small_spacing" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/medium_spacing" + android:layout_marginStart="@dimen/small_spacing" android:clickable="true" android:contentDescription="@string/new_conversation_dialog_back_button_content_description" android:focusable="true" @@ -36,9 +38,11 @@ <ImageView android:id="@+id/closeButton" + android:background="?selectableItemBackgroundBorderless" + android:padding="@dimen/small_spacing" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginEnd="@dimen/medium_spacing" + android:layout_marginEnd="@dimen/small_spacing" android:clickable="true" android:contentDescription="@string/new_conversation_dialog_close_button_content_description" android:focusable="true" @@ -56,11 +60,16 @@ android:layout_marginTop="@dimen/medium_spacing" android:layout_marginRight="@dimen/large_spacing" 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" @@ -90,18 +99,13 @@ tools:itemCount="5" tools:listitem="@layout/view_user" /> - <View - android:background="?conversation_menu_border_color" - android:layout_width="match_parent" - android:layout_height="1dp" - app:layout_constraintTop_toTopOf="@id/recyclerView"/> - <Button android:id="@+id/createClosedGroupButton" style="@style/Widget.Session.Button.Common.ProminentOutline" android:layout_width="196dp" android:layout_height="@dimen/medium_button_height" android:layout_marginVertical="@dimen/large_spacing" + android:contentDescription="@string/AccessibilityId_create_group" android:text="@string/activity_create_group_create_button_title" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" @@ -128,13 +132,6 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/titleText" /> - <View - android:layout_width="match_parent" - android:layout_height="@dimen/massive_spacing" - app:layout_constraintBottom_toBottomOf="@+id/recyclerView" - android:background="@drawable/conversation_menu_gradient" - /> - <Button android:id="@+id/createNewPrivateChatButton" style="@style/Widget.Session.Button.Common.ProminentOutline" diff --git a/app/src/main/res/layout/fragment_delete_message_bottom_sheet.xml b/app/src/main/res/layout/fragment_delete_message_bottom_sheet.xml index 031813a7c5..21bf4cd906 100644 --- a/app/src/main/res/layout/fragment_delete_message_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_delete_message_bottom_sheet.xml @@ -11,18 +11,21 @@ <TextView android:id="@+id/deleteForMeTextView" + android:contentDescription="@string/AccessibilityId_delete_just_for_me" style="@style/BottomSheetActionItem" android:text="@string/delete_message_for_me" android:textColor="@color/destructive"/> <TextView android:id="@+id/deleteForEveryoneTextView" + android:contentDescription="@string/delete_message_for_everyone" style="@style/BottomSheetActionItem" android:text="@string/delete_message_for_everyone" android:textColor="@color/destructive"/> <TextView android:id="@+id/cancelTextView" + android:contentDescription="@string/AccessibilityId_cancel_deletion" style="@style/BottomSheetActionItem" android:text="@string/cancel" /> 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_enter_public_key.xml b/app/src/main/res/layout/fragment_enter_public_key.xml index 65fe6db2f2..7549ee186d 100644 --- a/app/src/main/res/layout/fragment_enter_public_key.xml +++ b/app/src/main/res/layout/fragment_enter_public_key.xml @@ -33,6 +33,7 @@ android:layout_height="64dp" android:layout_marginHorizontal="@dimen/large_spacing" android:layout_marginTop="@dimen/large_spacing" + android:contentDescription="@string/AccessibilityId_session_id_input" android:gravity="center_vertical" android:hint="@string/fragment_enter_public_key_edit_text_hint" android:imeOptions="actionDone" @@ -69,7 +70,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/promptTextView"> - <org.thoughtcrime.securesms.components.LabeledSeparatorView + <include layout="@layout/view_separator" android:id="@+id/separatorView" android:layout_width="match_parent" android:layout_height="32dp" @@ -131,6 +132,7 @@ android:layout_width="196dp" android:layout_height="@dimen/medium_button_height" android:layout_marginVertical="@dimen/medium_spacing" + android:contentDescription="@string/AccessibilityId_next" android:text="@string/next" android:visibility="gone" app:layout_constraintBottom_toBottomOf="parent" diff --git a/app/src/main/res/layout/fragment_join_community.xml b/app/src/main/res/layout/fragment_join_community.xml index 79643510d7..e8bbdc0cc1 100644 --- a/app/src/main/res/layout/fragment_join_community.xml +++ b/app/src/main/res/layout/fragment_join_community.xml @@ -20,6 +20,8 @@ <ImageView android:id="@+id/backButton" + android:background="?selectableItemBackgroundBorderless" + android:padding="@dimen/small_spacing" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="@dimen/medium_spacing" @@ -34,6 +36,8 @@ <ImageView android:id="@+id/closeButton" + android:background="?selectableItemBackgroundBorderless" + android:padding="@dimen/small_spacing" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="@dimen/medium_spacing" @@ -47,13 +51,11 @@ <com.google.android.material.tabs.TabLayout android:id="@+id/tabLayout" - style="@style/Widget.Session.TabLayout" android:layout_width="match_parent" android:layout_height="@dimen/tab_bar_height" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/titleText" /> - <androidx.viewpager2.widget.ViewPager2 android:id="@+id/viewPager" android:layout_width="match_parent" diff --git a/app/src/main/res/layout/fragment_new_conversation_home.xml b/app/src/main/res/layout/fragment_new_conversation_home.xml index e9b36f4007..1e649dd968 100644 --- a/app/src/main/res/layout/fragment_new_conversation_home.xml +++ b/app/src/main/res/layout/fragment_new_conversation_home.xml @@ -32,11 +32,13 @@ android:id="@+id/closeButton" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:padding="@dimen/small_spacing" android:layout_marginStart="@dimen/large_spacing" android:clickable="true" android:contentDescription="@string/new_conversation_dialog_close_button_content_description" android:focusable="true" android:src="@drawable/ic_baseline_close_24" + android:background="?selectableItemBackgroundBorderless" app:layout_constraintBottom_toTopOf="@id/title_divider" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> @@ -59,6 +61,7 @@ <TextView android:id="@+id/createPrivateChatButton" + android:contentDescription="@string/AccessibilityId_new_direct_message" android:layout_width="match_parent" android:layout_height="@dimen/setting_button_height" android:drawablePadding="@dimen/large_spacing" @@ -67,6 +70,7 @@ android:text="@string/dialog_new_message_title" android:textColor="?android:textColorPrimary" android:textSize="@dimen/medium_font_size" + android:background="?selectableItemBackground" app:drawableStartCompat="@drawable/ic_message" app:layout_constraintBottom_toTopOf="@+id/new_message_divider" app:layout_constraintEnd_toEndOf="parent" @@ -84,6 +88,7 @@ <TextView android:id="@+id/createClosedGroupButton" + android:contentDescription="@string/AccessibilityId_create_group" android:layout_width="match_parent" android:layout_height="@dimen/setting_button_height" android:drawablePadding="@dimen/large_spacing" @@ -92,6 +97,7 @@ android:text="@string/activity_create_group_title" android:textColor="?android:textColorPrimary" android:textSize="@dimen/medium_font_size" + android:background="?selectableItemBackground" app:drawableStartCompat="@drawable/ic_group" app:layout_constraintBottom_toTopOf="@+id/create_group_divider" app:layout_constraintEnd_toEndOf="parent" @@ -109,6 +115,7 @@ <TextView android:id="@+id/joinCommunityButton" + android:contentDescription="@string/AccessibilityId_join_community" android:layout_width="match_parent" android:layout_height="@dimen/setting_button_height" android:drawablePadding="@dimen/large_spacing" @@ -117,6 +124,7 @@ android:text="@string/dialog_join_community_title" android:textColor="?android:textColorPrimary" android:textSize="@dimen/medium_font_size" + android:background="?selectableItemBackground" app:drawableStartCompat="@drawable/ic_globe" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/fragment_new_message.xml b/app/src/main/res/layout/fragment_new_message.xml index 2e9e6168c4..8a53b01950 100644 --- a/app/src/main/res/layout/fragment_new_message.xml +++ b/app/src/main/res/layout/fragment_new_message.xml @@ -21,6 +21,8 @@ <ImageView android:id="@+id/backButton" + android:background="?selectableItemBackgroundBorderless" + android:padding="@dimen/small_spacing" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="@dimen/medium_spacing" @@ -35,6 +37,8 @@ <ImageView android:id="@+id/closeButton" + android:background="?selectableItemBackgroundBorderless" + android:padding="@dimen/small_spacing" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="@dimen/medium_spacing" @@ -48,7 +52,6 @@ <com.google.android.material.tabs.TabLayout android:id="@+id/tabLayout" - style="@style/Widget.Session.TabLayout" android:layout_width="match_parent" android:layout_height="@dimen/tab_bar_height" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/fragment_recovery_phrase.xml b/app/src/main/res/layout/fragment_recovery_phrase.xml index 3e8d6e9f9f..674c194044 100644 --- a/app/src/main/res/layout/fragment_recovery_phrase.xml +++ b/app/src/main/res/layout/fragment_recovery_phrase.xml @@ -35,9 +35,9 @@ android:id="@+id/mnemonicEditText" android:layout_width="match_parent" android:layout_height="64dp" - android:layout_marginLeft="@dimen/very_large_spacing" + android:layout_marginHorizontal="@dimen/very_large_spacing" android:layout_marginTop="10dp" - android:layout_marginRight="@dimen/very_large_spacing" + android:contentDescription="@string/AccessibilityId_enter_your_recovery_phrase" android:paddingTop="0dp" android:paddingBottom="0dp" android:gravity="center_vertical" @@ -55,9 +55,9 @@ android:id="@+id/continueButton" 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:layout_marginHorizontal="@dimen/massive_spacing" android:layout_marginBottom="@dimen/medium_spacing" + android:contentDescription="@string/AccessibilityId_link_device" android:text="@string/continue_2" /> </LinearLayout> \ No newline at end of file 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 a856eaf6bb..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 @@ -13,7 +13,7 @@ app:behavior_hideable="true" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> - <include layout="@layout/view_profile_picture" + <org.thoughtcrime.securesms.components.ProfilePictureView android:id="@+id/profilePictureView" android:layout_width="@dimen/large_profile_picture_size" android:layout_height="@dimen/large_profile_picture_size" @@ -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" @@ -52,9 +55,12 @@ android:text="Spiderman" /> <ImageView + android:id="@+id/nameEditIcon" android:layout_width="20dp" 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> @@ -71,6 +77,8 @@ 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" /> <EditText @@ -79,13 +87,12 @@ 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" + android:contentDescription="@string/AccessibilityId_username" android:textAlignment="center" - android:paddingTop="12dp" - android:paddingBottom="12dp" + 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" /> @@ -94,6 +101,8 @@ 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" /> </LinearLayout> @@ -106,6 +115,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="@dimen/medium_spacing" + android:contentDescription="@string/AccessibilityId_session_id" android:textSize="@dimen/medium_font_size" android:textIsSelectable="true" tools:text="05987d601943c267879be41830888066c6a024cbdc9a548d06813924bf3372ea78" /> @@ -116,8 +126,8 @@ android:layout_width="wrap_content" android:layout_height="@dimen/medium_button_height" android:layout_marginTop="@dimen/medium_spacing" - android:paddingLeft="@dimen/large_spacing" - android:paddingRight="@dimen/large_spacing" + android:contentDescription="@string/AccessibilityId_message_user" + android:paddingHorizontal="@dimen/large_spacing" android:text="@string/ConversationActivity_message" /> </LinearLayout> diff --git a/app/src/main/res/layout/fragment_view_my_qr_code.xml b/app/src/main/res/layout/fragment_view_my_qr_code.xml index e99d4f6583..8bcdb43853 100644 --- a/app/src/main/res/layout/fragment_view_my_qr_code.xml +++ b/app/src/main/res/layout/fragment_view_my_qr_code.xml @@ -62,7 +62,7 @@ android:text="@string/fragment_view_my_qr_code_explanation" /> <Button - style="@style/Widget.Session.Button.Common.UnimportantOutline" + style="@style/Widget.Session.Button.Common.ProminentOutline" android:id="@+id/shareButton" android:layout_width="196dp" android:layout_height="@dimen/medium_button_height" diff --git a/app/src/main/res/layout/giphy_activity.xml b/app/src/main/res/layout/giphy_activity.xml index 4a03c09686..d54df8e8eb 100644 --- a/app/src/main/res/layout/giphy_activity.xml +++ b/app/src/main/res/layout/giphy_activity.xml @@ -28,7 +28,6 @@ android:id="@+id/tab_layout" android:layout_width="match_parent" android:layout_height="wrap_content" - android:theme="@style/Widget.Session.TabLayout" app:tabRippleColor="@color/cell_selected" app:tabIndicatorColor="?colorAccent" android:scrollbars="horizontal"/> diff --git a/app/src/main/res/layout/go_to_device_settings.xml b/app/src/main/res/layout/go_to_device_settings.xml deleted file mode 100644 index 97d452e53c..0000000000 --- a/app/src/main/res/layout/go_to_device_settings.xml +++ /dev/null @@ -1,14 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<org.thoughtcrime.securesms.preferences.widgets.NotificationSettingsPreference - xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="wrap_content"> - <TextView - android:textColor="?colorAccent" - android:id="@+id/device_settings_text" - android:text="@string/go_to_device_notification_settings" - android:paddingTop="@dimen/medium_spacing" - android:paddingBottom="@dimen/medium_spacing" - android:layout_width="match_parent" - android:layout_height="wrap_content"/> -</org.thoughtcrime.securesms.preferences.widgets.NotificationSettingsPreference> \ No newline at end of file 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/link_preview.xml b/app/src/main/res/layout/link_preview.xml deleted file mode 100644 index f76ad1010c..0000000000 --- a/app/src/main/res/layout/link_preview.xml +++ /dev/null @@ -1,104 +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" - xmlns:app="http://schemas.android.com/apk/res-auto"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:id="@+id/linkpreview_container" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:padding="@dimen/small_spacing" - android:background="?linkpreview_background_color"> - - <org.thoughtcrime.securesms.components.OutlinedThumbnailView - android:id="@+id/linkpreview_thumbnail" - android:layout_width="72dp" - android:layout_height="0dp" - android:scaleType="centerCrop" - android:visibility="gone" - app:layout_constraintBottom_toTopOf="@+id/linkpreview_divider" - app:layout_constraintHeight_min="72dp" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="@+id/linkpreview_title" - tools:src="@drawable/ic_contact_picture" - tools:visibility="visible" /> - - <org.thoughtcrime.securesms.components.emoji.EmojiTextView - android:id="@+id/linkpreview_title" - style="@style/Signal.Text.Body" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginEnd="8dp" - android:ellipsize="end" - android:fontFamily="sans-serif-medium" - android:maxLines="1" - android:textSize="@dimen/medium_font_size" - android:textColor="?linkpreview_primary_text_color" - app:layout_constraintEnd_toStartOf="@+id/linkpreview_close" - app:layout_constraintHorizontal_bias="0.0" - app:layout_constraintStart_toEndOf="@+id/linkpreview_thumbnail" - app:layout_constraintTop_toTopOf="parent" - tools:text="Wall Crawler Strikes Again!" /> - - <org.thoughtcrime.securesms.components.emoji.EmojiTextView - android:id="@+id/linkpreview_site" - style="@style/Signal.Text.Caption" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginTop="2dp" - android:maxLines="1" - android:textSize="@dimen/small_font_size" - android:textColor="?linkpreview_secondary_text_color" - android:alpha="0.6" - app:layout_constraintStart_toEndOf="@+id/linkpreview_thumbnail" - app:layout_constraintTop_toBottomOf="@+id/linkpreview_title" - tools:text="dailybugle.com" /> - - <View - android:id="@+id/linkpreview_divider" - android:layout_width="0dp" - android:layout_height="1dp" - android:layout_marginStart="8dp" - android:layout_marginTop="6dp" - android:background="?linkpreview_divider_color" - android:visibility="gone" - app:layout_constraintBottom_toBottomOf="@+id/linkpreview_thumbnail" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@+id/linkpreview_thumbnail" - app:layout_constraintTop_toBottomOf="@+id/linkpreview_site" - app:layout_constraintVertical_bias="0.0" - tools:visibility="visible" /> - - <ImageView - android:id="@+id/linkpreview_close" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginEnd="6dp" - android:layout_marginTop="4dp" - android:src="@drawable/ic_close_white_18dp" - android:visibility="gone" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:tint="@color/gray70" - tools:visibility="visible" /> - - <com.github.ybq.android.spinkit.SpinKitView - style="@style/SpinKitView.DoubleBounce" - android:id="@+id/linkpreview_progress_wheel" - android:layout_width="72dp" - android:layout_height="72dp" - android:layout_gravity="center" - android:padding="@dimen/small_spacing" - app:SpinKit_Color="?android:textColorPrimary" - android:visibility="gone" - app:layout_constraintBottom_toTopOf="@+id/linkpreview_divider" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - </androidx.constraintlayout.widget.ConstraintLayout> - -</merge> \ 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/media_overview_activity.xml b/app/src/main/res/layout/media_overview_activity.xml index eaa36d2194..6956adc454 100644 --- a/app/src/main/res/layout/media_overview_activity.xml +++ b/app/src/main/res/layout/media_overview_activity.xml @@ -21,7 +21,6 @@ <org.thoughtcrime.securesms.components.ControllableTabLayout android:id="@+id/tab_layout" - style="@style/Widget.Session.TabLayout" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top"/> diff --git a/app/src/main/res/layout/media_overview_gallery_item.xml b/app/src/main/res/layout/media_overview_gallery_item.xml index 6072611758..a4c3f324af 100644 --- a/app/src/main/res/layout/media_overview_gallery_item.xml +++ b/app/src/main/res/layout/media_overview_gallery_item.xml @@ -5,7 +5,7 @@ android:layout_height="match_parent" android:padding="2dp"> - <org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView + <include layout="@layout/thumbnail_view" android:id="@+id/image" android:layout_width="match_parent" android:layout_height="match_parent" diff --git a/app/src/main/res/layout/media_preview_activity.xml b/app/src/main/res/layout/media_preview_activity.xml index 2f6aaff454..78327b6cc3 100644 --- a/app/src/main/res/layout/media_preview_activity.xml +++ b/app/src/main/res/layout/media_preview_activity.xml @@ -22,7 +22,7 @@ </com.google.android.material.appbar.AppBarLayout> - <androidx.viewpager.widget.ViewPager + <org.thoughtcrime.securesms.components.SafeViewPager android:id="@+id/media_pager" android:layout_width="match_parent" android:layout_height="match_parent" diff --git a/app/src/main/res/layout/mediarail_media_item.xml b/app/src/main/res/layout/mediarail_media_item.xml index a6902de07f..b8501b1943 100644 --- a/app/src/main/res/layout/mediarail_media_item.xml +++ b/app/src/main/res/layout/mediarail_media_item.xml @@ -8,7 +8,7 @@ android:layout_margin="2dp" android:animateLayoutChanges="true"> - <org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView + <include layout="@layout/thumbnail_view" android:id="@+id/rail_item_image" android:layout_width="56dp" android:layout_height="56dp" diff --git a/app/src/main/res/layout/mediasend_fragment.xml b/app/src/main/res/layout/mediasend_fragment.xml index 4c6115b539..4ef993d529 100644 --- a/app/src/main/res/layout/mediasend_fragment.xml +++ b/app/src/main/res/layout/mediasend_fragment.xml @@ -95,7 +95,6 @@ <org.thoughtcrime.securesms.components.ComposeText style="@style/Widget.Session.EditText.Compose" - android:textColor="?android:textColorPrimary" android:id="@+id/mediasend_compose_text" android:layout_width="0dp" android:layout_height="wrap_content" 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/progress_dialog.xml b/app/src/main/res/layout/progress_dialog.xml index 29678e121f..c8c704ce0e 100644 --- a/app/src/main/res/layout/progress_dialog.xml +++ b/app/src/main/res/layout/progress_dialog.xml @@ -11,6 +11,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" - app:SpinKit_Color="?android:textColorPrimary" /> + app:SpinKit_Color="?colorAccent" /> </RelativeLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/reactions_bottom_sheet_dialog_fragment_recipient_item.xml b/app/src/main/res/layout/reactions_bottom_sheet_dialog_fragment_recipient_item.xml index 9616b18b56..ef94652a91 100644 --- a/app/src/main/res/layout/reactions_bottom_sheet_dialog_fragment_recipient_item.xml +++ b/app/src/main/res/layout/reactions_bottom_sheet_dialog_fragment_recipient_item.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="52dp"> - <include layout="@layout/view_profile_picture" + <org.thoughtcrime.securesms.components.ProfilePictureView android:id="@+id/reactions_bottom_view_avatar" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" 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/thumbnail_view.xml b/app/src/main/res/layout/thumbnail_view.xml index fcef98c9b0..a93e158209 100644 --- a/app/src/main/res/layout/thumbnail_view.xml +++ b/app/src/main/res/layout/thumbnail_view.xml @@ -1,8 +1,11 @@ <?xml version="1.0" encoding="utf-8"?> -<merge +<org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" - xmlns:app="http://schemas.android.com/apk/res-auto"> + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/transparent_black_6"> <ImageView android:id="@+id/thumbnail_image" @@ -60,4 +63,4 @@ android:layout="@layout/transfer_controls_stub" android:visibility="gone" /> -</merge> +</org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView> diff --git a/app/src/main/res/layout/view_contact.xml b/app/src/main/res/layout/view_contact.xml index 377b1ab900..ceb4304cc7 100644 --- a/app/src/main/res/layout/view_contact.xml +++ b/app/src/main/res/layout/view_contact.xml @@ -15,7 +15,7 @@ android:paddingHorizontal="@dimen/large_spacing" android:paddingVertical="@dimen/small_spacing"> - <include layout="@layout/view_profile_picture" + <org.thoughtcrime.securesms.components.ProfilePictureView android:id="@+id/profilePictureView" android:layout_width="@dimen/small_profile_picture_size" android:layout_height="@dimen/small_profile_picture_size" /> @@ -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 3fc5c6af35..56b0c91867 100644 --- a/app/src/main/res/layout/view_control_message.xml +++ b/app/src/main/res/layout/view_control_message.xml @@ -29,14 +29,56 @@ 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" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" 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 8f26f17c77..ed3cc66969 100644 --- a/app/src/main/res/layout/view_conversation.xml +++ b/app/src/main/res/layout/view_conversation.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> -<LinearLayout +<org.thoughtcrime.securesms.home.ConversationView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" @@ -14,7 +14,7 @@ android:layout_height="match_parent" android:background="?colorAccent" /> - <include layout="@layout/view_profile_picture" + <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" @@ -34,64 +34,100 @@ android:layout_gravity="center_vertical" android:orientation="vertical"> - <LinearLayout + <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="horizontal" - android:gravity="center_vertical"> + android:layout_height="wrap_content"> - <LinearLayout - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_weight="1" - android:orientation="horizontal" - android:gravity="center_vertical"> - - <TextView - android:id="@+id/conversationViewDisplayNameTextView" + <TextView + android:id="@+id/conversationViewDisplayNameTextView" + android:contentDescription="@string/AccessibilityId_conversation_list_item" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:drawablePadding="4dp" - android:maxLines="1" - android:ellipsize="end" - android:textAlignment="viewStart" - android:textSize="@dimen/medium_font_size" - android:textStyle="bold" - android:textColor="?android:textColorPrimary" - app:drawableTint="?conversation_pinned_icon_color" - tools:drawableRight="@drawable/ic_pin" - tools:text="I'm a very long display name. What are you going to do about it?" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toStartOf="@id/unreadCountIndicator" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constrainedWidth="true" + app:layout_constraintHorizontal_chainStyle="packed" + app:layout_constraintHorizontal_bias="0" + android:drawablePadding="4dp" + android:maxLines="1" + android:ellipsize="end" + android:textAlignment="viewStart" + android:textSize="@dimen/medium_font_size" + android:textStyle="bold" + android:textColor="?android:textColorPrimary" + app:drawableTint="?conversation_pinned_icon_color" + tools:drawableRight="@drawable/ic_pin" + tools:text="I'm a very long display name. What are you going to do about it?" /> - <RelativeLayout - android:id="@+id/unreadCountIndicator" + <RelativeLayout + android:id="@+id/unreadCountIndicator" + android:layout_width="wrap_content" + android:layout_height="20dp" + android:layout_marginStart="4dp" + app:layout_constraintStart_toEndOf="@id/conversationViewDisplayNameTextView" + app:layout_constraintEnd_toStartOf="@id/unreadMentionIndicator" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + android:minWidth="20dp" + android:maxWidth="40dp" + android:paddingLeft="4dp" + android:paddingRight="4dp" + android:background="@drawable/rounded_rectangle" + android:backgroundTint="?unreadIndicatorBackgroundColor"> + + <TextView + android:id="@+id/unreadCountTextView" android:layout_width="wrap_content" - android:maxWidth="40dp" - android:paddingLeft="4dp" - android:paddingRight="4dp" - android:layout_height="20dp" - android:layout_marginStart="4dp" - android:background="@drawable/rounded_rectangle" - android:backgroundTint="?unreadIndicatorBackgroundColor"> + android:layout_height="wrap_content" + android:layout_centerInParent="true" + android:textColor="?unreadIndicatorTextColor" + android:textSize="@dimen/very_small_font_size" + android:textStyle="bold" + tools:text="8" + tools:textColor="?android:textColorPrimary" /> - <TextView - android:id="@+id/unreadCountTextView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_centerInParent="true" - android:text="8" - android:textColor="?unreadIndicatorTextColor" - android:textSize="@dimen/very_small_font_size" - android:textStyle="bold" /> + </RelativeLayout> - </RelativeLayout> + <RelativeLayout + android:id="@+id/unreadMentionIndicator" + android:layout_width="wrap_content" + android:layout_height="20dp" + android:layout_marginStart="4dp" + app:layout_constraintStart_toEndOf="@id/unreadCountIndicator" + app:layout_constraintEnd_toStartOf="@id/timestampTextView" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + android:minWidth="20dp" + android:maxWidth="40dp" + android:paddingLeft="4dp" + android:paddingRight="4dp" + android:background="@drawable/rounded_rectangle" + android:backgroundTint="?unreadIndicatorBackgroundColor"> - </LinearLayout> + <TextView + android:id="@+id/unreadMentionTextView" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_centerInParent="true" + android:paddingBottom="3dp" + android:textColor="?unreadIndicatorTextColor" + android:textSize="@dimen/very_small_font_size" + android:textStyle="bold" + android:text="@" + tools:textColor="?android:textColorPrimary" /> + + </RelativeLayout> <TextView android:id="@+id/timestampTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/medium_spacing" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + android:paddingStart="@dimen/medium_spacing" android:maxLines="1" android:ellipsize="end" android:textSize="@dimen/small_font_size" @@ -99,7 +135,7 @@ android:alpha="0.4" tools:text="9:41 AM" /> - </LinearLayout> + </androidx.constraintlayout.widget.ConstraintLayout> <LinearLayout android:layout_width="match_parent" @@ -129,9 +165,9 @@ 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" /> - <org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorView + <include layout="@layout/view_typing_indicator" android:id="@+id/typingIndicatorView" android:layout_width="wrap_content" android:layout_height="wrap_content" @@ -152,4 +188,4 @@ </FrameLayout> -</LinearLayout> +</org.thoughtcrime.securesms.home.ConversationView> 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_conversation_typing_container.xml b/app/src/main/res/layout/view_conversation_typing_container.xml index d89c7c46df..ef5cda506d 100644 --- a/app/src/main/res/layout/view_conversation_typing_container.xml +++ b/app/src/main/res/layout/view_conversation_typing_container.xml @@ -17,7 +17,7 @@ android:background="@drawable/message_bubble_background_received_alone" android:backgroundTint="?message_received_background_color"> - <org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorView + <include layout="@layout/view_typing_indicator" android:id="@+id/typingIndicator" android:layout_width="wrap_content" android:layout_height="wrap_content" diff --git a/app/src/main/res/layout/view_deleted_message.xml b/app/src/main/res/layout/view_deleted_message.xml index 6ca774d285..ec0e167652 100644 --- a/app/src/main/res/layout/view_deleted_message.xml +++ b/app/src/main/res/layout/view_deleted_message.xml @@ -19,6 +19,7 @@ <TextView android:id="@+id/deleteTitleTextView" + android:contentDescription="@string/AccessibilityId_deleted_message" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="4dp" diff --git a/app/src/main/res/layout/view_document.xml b/app/src/main/res/layout/view_document.xml index f1f57a6398..c760a1a592 100644 --- a/app/src/main/res/layout/view_document.xml +++ b/app/src/main/res/layout/view_document.xml @@ -7,12 +7,20 @@ xmlns:tools="http://schemas.android.com/tools" android:orientation="horizontal" android:padding="@dimen/medium_spacing" - android:gravity="center"> + 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_emoji_reactions.xml b/app/src/main/res/layout/view_emoji_reactions.xml index 38730a1a63..1ff72b7dfe 100644 --- a/app/src/main/res/layout/view_emoji_reactions.xml +++ b/app/src/main/res/layout/view_emoji_reactions.xml @@ -1,9 +1,10 @@ <?xml version="1.0" encoding="utf-8"?> -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="wrap_content" +<org.thoughtcrime.securesms.conversation.v2.messages.EmojiReactionsView + 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="wrap_content" android:padding="@dimen/small_spacing" android:gravity="center"> @@ -65,4 +66,4 @@ android:visibility="gone" app:constraint_referenced_ids="image_view_show_less, text_view_show_less"/> -</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file +</org.thoughtcrime.securesms.conversation.v2.messages.EmojiReactionsView> \ No newline at end of file diff --git a/app/src/main/res/layout/view_global_search_input.xml b/app/src/main/res/layout/view_global_search_input.xml index 9036492ac6..d559d52aeb 100644 --- a/app/src/main/res/layout/view_global_search_input.xml +++ b/app/src/main/res/layout/view_global_search_input.xml @@ -39,13 +39,15 @@ android:layout_height="wrap_content"/> <ImageView android:id="@+id/search_clear" + android:background="?selectableItemBackgroundBorderless" android:src="@drawable/ic_close_white_18dp" android:layout_gravity="center_vertical" android:layout_width="16dp" android:layout_height="16dp" app:tint="?searchIconColor" /> </LinearLayout> - <TextView + <Button + style="@style/Widget.Session.Button.Common.Borderless" android:layout_marginStart="@dimen/small_spacing" android:layout_centerVertical="true" android:text="@string/cancel" diff --git a/app/src/main/res/layout/view_global_search_result.xml b/app/src/main/res/layout/view_global_search_result.xml index 23c35a6281..cdd90b8d97 100644 --- a/app/src/main/res/layout/view_global_search_result.xml +++ b/app/src/main/res/layout/view_global_search_result.xml @@ -18,7 +18,7 @@ android:id="@+id/search_result_profile_picture_parent" android:layout_width="wrap_content" android:layout_height="wrap_content"> - <include layout="@layout/view_profile_picture" + <org.thoughtcrime.securesms.components.ProfilePictureView android:visibility="gone" android:id="@+id/search_result_profile_picture" android:layout_width="@dimen/medium_profile_picture_size" diff --git a/app/src/main/res/layout/view_input_bar.xml b/app/src/main/res/layout/view_input_bar.xml index 83ce5b8927..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" /> @@ -24,6 +27,7 @@ <RelativeLayout android:id="@+id/attachmentsButtonContainer" + android:contentDescription="@string/AccessibilityId_attachments_button" android:layout_width="@dimen/input_bar_button_expanded_size" android:layout_height="@dimen/input_bar_button_expanded_size" android:layout_alignParentStart="true" @@ -32,6 +36,7 @@ <org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarEditText android:id="@+id/inputBarEditText" + android:contentDescription="@string/AccessibilityId_message_input" android:inputType="textMultiLine" android:layout_width="match_parent" android:layout_height="40dp" diff --git a/app/src/main/res/layout/view_link_preview.xml b/app/src/main/res/layout/view_link_preview.xml index 096ff5dac9..dd2e133bea 100644 --- a/app/src/main/res/layout/view_link_preview.xml +++ b/app/src/main/res/layout/view_link_preview.xml @@ -1,54 +1,47 @@ <?xml version="1.0" encoding="utf-8"?> -<LinearLayout android:id="@+id/mainLinkPreviewContainer" +<org.thoughtcrime.securesms.conversation.v2.messages.LinkPreviewView xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/mainLinkPreviewContainer" + android:background="@color/transparent_black_6" android:layout_width="300dp" android:layout_height="wrap_content" - xmlns:tools="http://schemas.android.com/tools" - android:orientation="vertical" - xmlns:app="http://schemas.android.com/apk/res-auto"> + android:orientation="horizontal" + android:gravity="center"> - <LinearLayout - android:background="@color/transparent_black_6" - android:id="@+id/mainLinkPreviewParent" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="horizontal" - android:gravity="center"> + <RelativeLayout + android:layout_width="96dp" + android:layout_height="96dp"> - <RelativeLayout - android:layout_width="96dp" - android:layout_height="96dp"> + <ImageView + android:layout_width="24dp" + android:layout_height="24dp" + android:layout_centerInParent="true" + android:src="@drawable/ic_link" + app:tint="?android:textColorPrimary" /> - <ImageView - android:layout_width="24dp" - android:layout_height="24dp" - android:layout_centerInParent="true" - android:src="@drawable/ic_link" - app:tint="?android:textColorPrimary" /> - - <org.thoughtcrime.securesms.conversation.v2.utilities.KThumbnailView - android:background="@color/transparent_black_6" - android:id="@+id/thumbnailImageView" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:scaleType="centerCrop" /> - - </RelativeLayout> - - <TextView - android:id="@+id/titleTextView" + <include layout="@layout/thumbnail_view" + android:background="@color/transparent_black_6" + android:id="@+id/thumbnailImageView" android:layout_width="match_parent" android:layout_height="match_parent" - android:paddingHorizontal="12dp" - android:gravity="center_vertical" - android:textSize="@dimen/small_font_size" - android:textStyle="bold" - tools:text="Some Text here" - android:minWidth="@dimen/media_bubble_min_width" - android:maxLines="3" - android:ellipsize="end" - android:textColor="?android:textColorPrimary"/> + android:scaleType="centerCrop" /> - </LinearLayout> + </RelativeLayout> -</LinearLayout> \ No newline at end of file + <TextView + android:id="@+id/titleTextView" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:paddingHorizontal="12dp" + android:gravity="center_vertical" + android:textSize="@dimen/small_font_size" + android:textStyle="bold" + tools:text="Some Text here" + android:minWidth="@dimen/media_bubble_min_width" + android:maxLines="3" + android:ellipsize="end" + android:textColor="?android:textColorPrimary"/> + +</org.thoughtcrime.securesms.conversation.v2.messages.LinkPreviewView> \ No newline at end of file diff --git a/app/src/main/res/layout/view_link_preview_draft.xml b/app/src/main/res/layout/view_link_preview_draft.xml index 65e2cf7fd5..bf7cd3ebb3 100644 --- a/app/src/main/res/layout/view_link_preview_draft.xml +++ b/app/src/main/res/layout/view_link_preview_draft.xml @@ -26,7 +26,7 @@ android:src="@drawable/ic_link" app:tint="?android:textColorPrimary" /> - <org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView + <include layout="@layout/thumbnail_view" android:id="@+id/thumbnailImageView" android:layout_width="match_parent" android:layout_height="match_parent" diff --git a/app/src/main/res/layout/view_mention_candidate.xml b/app/src/main/res/layout/view_mention_candidate.xml index 681045ee35..bd96b482a5 100644 --- a/app/src/main/res/layout/view_mention_candidate.xml +++ b/app/src/main/res/layout/view_mention_candidate.xml @@ -13,7 +13,7 @@ android:layout_width="26dp" android:layout_height="32dp"> - <include layout="@layout/view_profile_picture" + <org.thoughtcrime.securesms.components.ProfilePictureView android:id="@+id/profilePictureView" android:layout_width="@dimen/very_small_profile_picture_size" android:layout_height="@dimen/very_small_profile_picture_size" diff --git a/app/src/main/res/layout/view_mention_candidate_v2.xml b/app/src/main/res/layout/view_mention_candidate_v2.xml index 31ade71b2e..f81dd73f84 100644 --- a/app/src/main/res/layout/view_mention_candidate_v2.xml +++ b/app/src/main/res/layout/view_mention_candidate_v2.xml @@ -17,7 +17,7 @@ android:layout_width="26dp" android:layout_height="32dp"> - <include layout="@layout/view_profile_picture" + <org.thoughtcrime.securesms.components.ProfilePictureView android:id="@+id/profilePictureView" android:layout_width="@dimen/very_small_profile_picture_size" android:layout_height="@dimen/very_small_profile_picture_size" @@ -29,7 +29,8 @@ android:layout_height="16dp" android:src="@drawable/ic_crown" android:layout_alignParentEnd="true" - android:layout_alignParentBottom="true" /> + android:layout_alignParentBottom="true" + android:contentDescription="@string/AccessibilityId_contact_mentions"/> </RelativeLayout> @@ -41,6 +42,7 @@ android:textSize="@dimen/small_font_size" android:textColor="?android:textColorPrimary" android:maxLines="1" + android:contentDescription="@string/AccessibilityId_contact_mentions" android:ellipsize="end" /> </LinearLayout> diff --git a/app/src/main/res/layout/view_message_request.xml b/app/src/main/res/layout/view_message_request.xml index 9d62f658c0..c85a91fbc9 100644 --- a/app/src/main/res/layout/view_message_request.xml +++ b/app/src/main/res/layout/view_message_request.xml @@ -1,12 +1,13 @@ <?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:contentDescription="@string/AccessibilityId_message_request" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center_vertical" android:orientation="horizontal"> - <include layout="@layout/view_profile_picture" + <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" diff --git a/app/src/main/res/layout/view_message_request_banner.xml b/app/src/main/res/layout/view_message_request_banner.xml index 79698b3e19..c03e9c1cdf 100644 --- a/app/src/main/res/layout/view_message_request_banner.xml +++ b/app/src/main/res/layout/view_message_request_banner.xml @@ -5,6 +5,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/conversation_view_background" + android:contentDescription="@string/AccessibilityId_message_request_banner" android:gravity="center_vertical" android:orientation="horizontal" android:paddingStart="@dimen/accent_line_thickness" @@ -71,11 +72,15 @@ android:alpha="0.4" android:ellipsize="end" android:maxLines="1" + android:textAlignment="textEnd" android:textColor="?android:textColorPrimary" android:textSize="@dimen/small_font_size" + app:layout_constrainedWidth="true" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="1" + app:layout_constraintStart_toEndOf="@id/unreadCountIndicator" app:layout_constraintTop_toTopOf="parent" - tools:text="9:41 AM" /> + tools:text="11 Apr, 9:41 AM" /> -</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file +</androidx.constraintlayout.widget.ConstraintLayout> 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_profile_picture.xml b/app/src/main/res/layout/view_profile_picture.xml index 876dfe8eb8..a5205e9e3f 100644 --- a/app/src/main/res/layout/view_profile_picture.xml +++ b/app/src/main/res/layout/view_profile_picture.xml @@ -1,8 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> -<org.thoughtcrime.securesms.components.ProfilePictureView - xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="match_parent"> +<merge xmlns:android="http://schemas.android.com/apk/res/android"> <RelativeLayout android:id="@+id/doubleModeImageViewContainer" @@ -15,6 +12,7 @@ android:layout_height="@dimen/small_profile_picture_size" android:layout_alignParentLeft="true" android:layout_alignParentTop="true" + android:scaleType="centerCrop" android:background="@drawable/profile_picture_view_small_background" /> <ImageView @@ -23,11 +21,13 @@ android:layout_height="@dimen/small_profile_picture_size" android:layout_alignParentRight="true" android:layout_alignParentBottom="true" + android:scaleType="centerCrop" android:background="@drawable/profile_picture_view_small_background" /> </RelativeLayout> <ImageView + android:scaleType="centerCrop" android:id="@+id/singleModeImageView" android:layout_width="@dimen/medium_profile_picture_size" android:layout_height="@dimen/medium_profile_picture_size" @@ -35,8 +35,9 @@ <ImageView android:id="@+id/largeSingleModeImageView" + android:scaleType="centerCrop" android:layout_width="@dimen/large_profile_picture_size" android:layout_height="@dimen/large_profile_picture_size" android:background="@drawable/profile_picture_view_large_background" /> -</org.thoughtcrime.securesms.components.ProfilePictureView> \ No newline at end of file +</merge> \ No newline at end of file diff --git a/app/src/main/res/layout/view_quote.xml b/app/src/main/res/layout/view_quote.xml index d9da4a1b62..8f1ca06e5e 100644 --- a/app/src/main/res/layout/view_quote.xml +++ b/app/src/main/res/layout/view_quote.xml @@ -44,7 +44,7 @@ android:scaleType="centerInside" android:src="@drawable/ic_microphone" /> - <org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView + <include layout="@layout/thumbnail_view" android:id="@+id/quoteViewAttachmentThumbnailImageView" android:layout_width="match_parent" android:layout_height="match_parent" diff --git a/app/src/main/res/layout/view_quote_draft.xml b/app/src/main/res/layout/view_quote_draft.xml index 86861a2bf1..05796c986b 100644 --- a/app/src/main/res/layout/view_quote_draft.xml +++ b/app/src/main/res/layout/view_quote_draft.xml @@ -44,7 +44,7 @@ android:scaleType="centerInside" android:src="@drawable/ic_microphone" /> - <org.thoughtcrime.securesms.conversation.v2.utilities.ThumbnailView + <include layout="@layout/thumbnail_view" android:id="@+id/quoteViewAttachmentThumbnailImageView" android:layout_width="match_parent" android:layout_height="match_parent" diff --git a/app/src/main/res/layout/view_seed_reminder.xml b/app/src/main/res/layout/view_seed_reminder.xml index 2d30fbe60c..850ceaef5a 100644 --- a/app/src/main/res/layout/view_seed_reminder.xml +++ b/app/src/main/res/layout/view_seed_reminder.xml @@ -19,6 +19,7 @@ <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" + android:contentDescription="@string/AccessibilityId_recovery_phrase_reminder" android:gravity="center_vertical" android:padding="@dimen/medium_spacing" android:orientation="horizontal"> @@ -58,6 +59,7 @@ <Button style="@style/Widget.Session.Button.Common.ProminentOutline" + android:contentDescription="@string/AccessibilityId_continue" android:id="@+id/button" android:layout_width="wrap_content" android:layout_height="28dp" diff --git a/app/src/main/res/layout/view_separator.xml b/app/src/main/res/layout/view_separator.xml index 2b808a6c82..aca7f26d23 100644 --- a/app/src/main/res/layout/view_separator.xml +++ b/app/src/main/res/layout/view_separator.xml @@ -1,17 +1,33 @@ <?xml version="1.0" encoding="utf-8"?> -<RelativeLayout +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_height="wrap_content"> - <TextView - android:id="@+id/titleTextView" + <View + android:layout_gravity="center" + android:background="?colorDividerBackground" + android:layout_width="match_parent" + android:layout_height="1dp"/> + + <FrameLayout + android:layout_gravity="center" + android:background="@drawable/view_separator" + android:paddingTop="4dp" + android:paddingBottom="4dp" + android:paddingStart="16dp" + android:paddingEnd="16dp" android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_centerInParent="true" - android:gravity="center" - android:textColor="?android:textColorTertiary" - android:textSize="@dimen/small_font_size" - android:text="@string/your_session_id" /> + android:layout_height="wrap_content"> -</RelativeLayout> \ No newline at end of file + <TextView + android:id="@+id/titleTextView" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center" + android:textColor="?android:textColorTertiary" + android:textSize="@dimen/small_font_size" + android:text="@string/your_session_id" /> + </FrameLayout> + +</FrameLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/view_typing_indicator.xml b/app/src/main/res/layout/view_typing_indicator.xml index b48c554476..805408c787 100644 --- a/app/src/main/res/layout/view_typing_indicator.xml +++ b/app/src/main/res/layout/view_typing_indicator.xml @@ -1,11 +1,10 @@ <?xml version="1.0" encoding="utf-8"?> -<merge +<org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:orientation="horizontal" android:layout_width="match_parent" - android:layout_height="match_parent" - tools:context="org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorView"> + android:layout_height="match_parent"> <View android:id="@+id/typing_dot1" @@ -37,4 +36,4 @@ android:alpha="0.5" android:background="@drawable/circle_white" /> -</merge> \ No newline at end of file +</org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorView> \ No newline at end of file diff --git a/app/src/main/res/layout/view_untrusted_attachment.xml b/app/src/main/res/layout/view_untrusted_attachment.xml index f9d604b5d8..92d1db04ea 100644 --- a/app/src/main/res/layout/view_untrusted_attachment.xml +++ b/app/src/main/res/layout/view_untrusted_attachment.xml @@ -2,9 +2,10 @@ <org.thoughtcrime.securesms.conversation.v2.messages.UntrustedAttachmentView 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="wrap_content" - xmlns:tools="http://schemas.android.com/tools" + android:contentDescription="@string/AccessibilityId_untrusted_attachment_message" android:orientation="horizontal" android:padding="@dimen/medium_spacing" android:gravity="center"> diff --git a/app/src/main/res/layout/view_user.xml b/app/src/main/res/layout/view_user.xml index 2dfd0d2f67..177b3ff6c9 100644 --- a/app/src/main/res/layout/view_user.xml +++ b/app/src/main/res/layout/view_user.xml @@ -4,6 +4,7 @@ android:layout_width="match_parent" android:layout_height="64dp" android:background="@drawable/conversation_view_background" + android:contentDescription="@string/AccessibilityId_contact" android:orientation="vertical"> <LinearLayout @@ -14,7 +15,7 @@ android:gravity="center_vertical" android:paddingHorizontal="@dimen/medium_spacing"> - <include layout="@layout/view_profile_picture" + <org.thoughtcrime.securesms.components.ProfilePictureView android:id="@+id/profilePictureView" android:layout_width="@dimen/small_profile_picture_size" android:layout_height="@dimen/small_profile_picture_size" @@ -23,6 +24,7 @@ <TextView android:id="@+id/nameTextView" + android:contentDescription="@string/AccessibilityId_contact" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="@dimen/medium_spacing" @@ -36,6 +38,7 @@ <ImageView android:id="@+id/actionIndicatorImageView" + android:contentDescription="@string/AccessibilityId_select_contact" android:layout_width="24dp" android:layout_height="24dp" android:layout_marginStart="@dimen/medium_spacing" diff --git a/app/src/main/res/layout/view_visible_message.xml b/app/src/main/res/layout/view_visible_message.xml index 48c2d1d8e0..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,6 @@ <?xml version="1.0" encoding="utf-8"?> -<org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView xmlns:android="http://schemas.android.com/apk/res/android" +<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:id="@+id/visibleMessageView" @@ -7,6 +8,12 @@ android:layout_height="wrap_content" android:orientation="vertical"> + <ViewStub + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:id="@+id/unreadMarkerContainerStub" + android:layout="@layout/viewstub_visible_message_marker_container" /> + <TextView android:id="@+id/dateBreakTextView" android:layout_width="match_parent" @@ -21,19 +28,13 @@ android:id="@+id/mainContainer" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginStart="@dimen/small_spacing" + android:layout_marginEnd="@dimen/small_spacing" android:gravity="bottom" android:paddingBottom="@dimen/small_spacing"> - <View - android:id="@+id/startSpacing" - android:layout_width="8dp" - android:layout_height="1dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toStartOf="parent" /> - - <include + <org.thoughtcrime.securesms.components.ProfilePictureView android:id="@+id/profilePictureView" - layout="@layout/view_profile_picture" android:layout_marginBottom="@dimen/small_spacing" android:layout_marginEnd="@dimen/small_spacing" android:layout_width="@dimen/very_small_profile_picture_size" @@ -41,7 +42,7 @@ android:layout_gravity="center" app:layout_constraintEnd_toStartOf="@+id/messageInnerContainer" app:layout_constraintBottom_toBottomOf="@id/messageInnerContainer" - app:layout_constraintStart_toEndOf="@+id/startSpacing" + app:layout_constraintStart_toStartOf="parent" tools:visibility="visible" /> <ImageView @@ -71,66 +72,76 @@ app:layout_constraintTop_toTopOf="parent" tools:text="@tools:sample/full_names" /> - <LinearLayout + <FrameLayout android:id="@+id/messageInnerContainer" android:layout_width="0dp" android:layout_height="wrap_content" - android:orientation="horizontal" app:layout_constraintBottom_toBottomOf="@+id/profilePictureView" - app:layout_constraintEnd_toStartOf="@+id/messageTimestampContainer" app:layout_constraintStart_toEndOf="@+id/profilePictureView" + app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/senderNameTextView"> - <org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentView - android:id="@+id/messageContentView" + <LinearLayout + android:id="@+id/messageInnerLayout" android:layout_width="wrap_content" - android:layout_height="wrap_content" /> + android:layout_height="wrap_content" + android:orientation="horizontal"> + + <include layout="@layout/view_visible_message_content" + android:id="@+id/messageContentView" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + + </LinearLayout> + </FrameLayout> + + <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" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="@+id/messageInnerContainer" + app:layout_constraintTop_toBottomOf="@id/messageInnerContainer" /> + + <LinearLayout + android:id="@+id/statusContainer" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:baselineAligned="true" + app:layout_constraintTop_toBottomOf="@+id/emojiReactionsView" + 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_vertical" - android:layout_marginHorizontal="@dimen/small_spacing" - android:visibility="invisible" - tools:visibility="visible" /> - - <View - android:id="@+id/messageContentSpacing" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_weight="1" - android:minWidth="@dimen/very_large_spacing" /> + android:layout_gravity="center" + android:tint="?message_status_color" /> </LinearLayout> - <org.thoughtcrime.securesms.conversation.v2.messages.EmojiReactionsView - android:id="@+id/emojiReactionsView" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:visibility="gone" - app:layout_constraintEnd_toStartOf="@+id/messageTimestampContainer" - app:layout_constraintStart_toStartOf="@+id/messageInnerContainer" - app:layout_constraintTop_toBottomOf="@id/messageInnerContainer" /> - - <RelativeLayout - android:id="@+id/messageTimestampContainer" - android:layout_width="@dimen/medium_spacing" - android:layout_height="wrap_content" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintBottom_toBottomOf="@+id/messageInnerContainer"> - - <ImageView - android:id="@+id/messageStatusImageView" - android:layout_width="16dp" - android:layout_height="16dp" - android:layout_alignParentEnd="true" - android:layout_centerVertical="true" - android:padding="2dp" - android:src="@drawable/ic_delivery_status_sent" /> - - </RelativeLayout> - </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 edd5e5cbfa..2c56229a8a 100644 --- a/app/src/main/res/layout/view_visible_message_content.xml +++ b/app/src/main/res/layout/view_visible_message_content.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> -<androidx.constraintlayout.widget.ConstraintLayout +<org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="wrap_content" android:layout_height="wrap_content" @@ -7,7 +7,7 @@ android:id="@+id/mainContainerConstraint" xmlns:app="http://schemas.android.com/apk/res-auto"> - <androidx.constraintlayout.widget.ConstraintLayout + <org.thoughtcrime.securesms.util.MessageBubbleView android:id="@+id/contentParent" android:layout_width="wrap_content" android:layout_height="wrap_content" @@ -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,12 +66,21 @@ android:layout_width="wrap_content" android:layout_height="wrap_content"/> - <org.thoughtcrime.securesms.conversation.v2.messages.LinkPreviewView + <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" android:visibility="gone" android:id="@+id/linkPreviewView" - android:layout_width="wrap_content" + android:layout_width="300dp" android:layout_height="wrap_content"/> <androidx.constraintlayout.widget.Barrier @@ -99,6 +99,7 @@ app:barrierDirection="bottom"/> <org.thoughtcrime.securesms.components.emoji.EmojiTextView + android:contentDescription="@string/AccessibilityId_message_body" app:layout_constraintHorizontal_bias="0" tools:visibility="visible" android:visibility="gone" @@ -110,10 +111,10 @@ android:id="@+id/bodyTextView" android:layout_width="wrap_content" android:layout_height="wrap_content"/> - </androidx.constraintlayout.widget.ConstraintLayout> + </org.thoughtcrime.securesms.util.MessageBubbleView> - <org.thoughtcrime.securesms.conversation.v2.components.AlbumThumbnailView - android:visibility="visible" + <include layout="@layout/album_thumbnail_view" + android:visibility="gone" android:id="@+id/albumThumbnailView" android:layout_marginTop="4dp" app:layout_constraintTop_toBottomOf="@+id/contentParent" @@ -123,4 +124,4 @@ android:layout_width="wrap_content" android:layout_height="wrap_content"/> -</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file +</org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageContentView> \ No newline at end of file diff --git a/app/src/main/res/layout/view_voice_message.xml b/app/src/main/res/layout/view_voice_message.xml index 535c7f2349..2953695f69 100644 --- a/app/src/main/res/layout/view_voice_message.xml +++ b/app/src/main/res/layout/view_voice_message.xml @@ -3,6 +3,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/mainVoiceMessageViewContainer" + android:contentDescription="@string/AccessibilityId_voice_message" android:layout_width="160dp" android:layout_height="36dp"> @@ -11,7 +12,7 @@ android:layout_width="0dp" android:layout_height="match_parent" android:layout_alignParentStart="true" - android:background="@color/transparent_black_6" /> + android:background="@color/transparent_black_30" /> <View android:layout_width="84dp" 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.xml b/app/src/main/res/menu/menu_conversation.xml index 3be7af2964..de7bd8e1c2 100644 --- a/app/src/main/res/menu/menu_conversation.xml +++ b/app/src/main/res/menu/menu_conversation.xml @@ -5,11 +5,13 @@ <item android:title="@string/conversation__menu_view_all_media" - android:id="@+id/menu_view_all_media" /> + android:id="@+id/menu_view_all_media" + android:contentDescription="@string/AccessibilityId_all_media"/> <item android:title="@string/SearchToolbar_search" android:id="@+id/menu_search" + android:contentDescription="@string/AccessibilityId_search" app:actionViewClass="androidx.appcompat.widget.SearchView" app:showAsAction="collapseActionView" /> diff --git a/app/src/main/res/menu/menu_conversation_block.xml b/app/src/main/res/menu/menu_conversation_block.xml index 82bb05d500..4c499eb845 100644 --- a/app/src/main/res/menu/menu_conversation_block.xml +++ b/app/src/main/res/menu/menu_conversation_block.xml @@ -4,6 +4,7 @@ <item android:title="@string/recipient_preferences__block" + android:contentDescription="@string/AccessibilityId_block" android:id="@+id/menu_block" /> </menu> \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_call.xml b/app/src/main/res/menu/menu_conversation_call.xml index 8ebfeb8c8d..1fa6955af7 100644 --- a/app/src/main/res/menu/menu_conversation_call.xml +++ b/app/src/main/res/menu/menu_conversation_call.xml @@ -3,6 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> <item android:title="@string/conversation_context__menu_call" + android:contentDescription="@string/AccessibilityId_call_button" android:icon="@drawable/ic_baseline_call_24" app:showAsAction="always" android:id="@+id/menu_call"/> diff --git a/app/src/main/res/menu/menu_conversation_closed_group.xml b/app/src/main/res/menu/menu_conversation_closed_group.xml index 7ab02e07d9..979d2afbf8 100644 --- a/app/src/main/res/menu/menu_conversation_closed_group.xml +++ b/app/src/main/res/menu/menu_conversation_closed_group.xml @@ -5,11 +5,13 @@ <item android:id="@+id/menu_edit_group" + android:contentDescription="@string/AccessibilityId_edit_group" android:title="@string/conversation__menu_edit_group" app:showAsAction="collapseActionView" /> <item android:id="@+id/menu_leave_group" + android:contentDescription="@string/AccessibilityId_leave_group" android:title="@string/conversation__menu_leave_group" app:showAsAction="collapseActionView"/> diff --git a/app/src/main/res/menu/menu_conversation_copy_session_id.xml b/app/src/main/res/menu/menu_conversation_copy_session_id.xml index 4ca49666cd..68a546ba9f 100644 --- a/app/src/main/res/menu/menu_conversation_copy_session_id.xml +++ b/app/src/main/res/menu/menu_conversation_copy_session_id.xml @@ -5,6 +5,7 @@ <item android:title="@string/activity_conversation_menu_copy_session_id" android:id="@+id/menu_copy_session_id" - android:icon="@drawable/ic_content_copy_white_24dp" /> + android:icon="@drawable/ic_content_copy_white_24dp" + android:contentDescription="@string/AccessibilityId_copy_session_id"/> </menu> \ No newline at end of file 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 60% rename from app/src/main/res/menu/menu_conversation_expiration_off.xml rename to app/src/main/res/menu/menu_conversation_expiration.xml index 8e062dc644..e589252237 100644 --- a/app/src/main/res/menu/menu_conversation_expiration_off.xml +++ b/app/src/main/res/menu/menu_conversation_expiration.xml @@ -4,7 +4,7 @@ <item android:title="@string/conversation_expiring_off__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 2abb49cef6..0000000000 --- a/app/src/main/res/menu/menu_conversation_expiration_on.xml +++ /dev/null @@ -1,12 +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" - 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/menu/menu_conversation_item_action.xml b/app/src/main/res/menu/menu_conversation_item_action.xml index 4c7243e83c..ea81685617 100644 --- a/app/src/main/res/menu/menu_conversation_item_action.xml +++ b/app/src/main/res/menu/menu_conversation_item_action.xml @@ -26,17 +26,17 @@ android:id="@+id/menu_message_details" app:showAsAction="never" /> - <item - android:title="@string/conversation_context__menu_message_details" - android:id="@+id/menu_context_select_message" - app:showAsAction="never" /> - <item android:title="@string/conversation_context__menu_copy_text" android:id="@+id/menu_context_copy" android:icon="?menu_copy_icon" app:showAsAction="always" /> + <item + android:title="@string/conversation_context__menu_resync_message" + android:id="@+id/menu_context_resync" + app:showAsAction="never" /> + <item android:title="@string/conversation_context__menu_resend_message" android:id="@+id/menu_context_resend" diff --git a/app/src/main/res/menu/menu_conversation_notification_settings.xml b/app/src/main/res/menu/menu_conversation_notification_settings.xml index 2275db01f2..3ea3037f59 100644 --- a/app/src/main/res/menu/menu_conversation_notification_settings.xml +++ b/app/src/main/res/menu/menu_conversation_notification_settings.xml @@ -2,5 +2,6 @@ <menu xmlns:android="http://schemas.android.com/apk/res/android"> <item android:title="@string/RecipientPreferenceActivity_notification_settings" - android:id="@+id/menu_notification_settings"/> + android:id="@+id/menu_notification_settings" + android:contentDescription="@string/AccessibilityId_notification_settings"/> </menu> \ No newline at end of file diff --git a/app/src/main/res/menu/menu_conversation_open_group.xml b/app/src/main/res/menu/menu_conversation_open_group.xml index 6ff025aadb..1bbb2d76de 100644 --- a/app/src/main/res/menu/menu_conversation_open_group.xml +++ b/app/src/main/res/menu/menu_conversation_open_group.xml @@ -2,6 +2,10 @@ <menu xmlns:android="http://schemas.android.com/apk/res/android"> + <item + android:title="@string/ConversationActivity_copy_open_group_url" + android:id="@+id/menu_copy_open_group_url" /> + <item android:title="@string/ConversationActivity_invite_to_open_group" android:id="@+id/menu_invite_to_open_group" /> diff --git a/app/src/main/res/menu/menu_conversation_unmuted.xml b/app/src/main/res/menu/menu_conversation_unmuted.xml index 85b9d16e80..7cc5c094b5 100644 --- a/app/src/main/res/menu/menu_conversation_unmuted.xml +++ b/app/src/main/res/menu/menu_conversation_unmuted.xml @@ -4,6 +4,7 @@ <item android:title="@string/conversation_unmuted__mute_notifications" - android:id="@+id/menu_mute_notifications" /> + android:id="@+id/menu_mute_notifications" + android:contentDescription="@string/AccessibilityId_mute_notifications" /> </menu> \ No newline at end of file diff --git a/app/src/main/res/menu/menu_done.xml b/app/src/main/res/menu/menu_done.xml index 9ee181de1e..0919791727 100644 --- a/app/src/main/res/menu/menu_done.xml +++ b/app/src/main/res/menu/menu_done.xml @@ -6,6 +6,7 @@ android:title="@string/menu_done_button" android:id="@+id/doneButton" android:icon="?menu_accept_icon" + android:contentDescription="@string/AccessibilityId_done" app:showAsAction="always" /> </menu> diff --git a/app/src/main/res/menu/menu_edit_closed_group.xml b/app/src/main/res/menu/menu_edit_closed_group.xml index 0c6b915d05..bcf5c784ec 100644 --- a/app/src/main/res/menu/menu_edit_closed_group.xml +++ b/app/src/main/res/menu/menu_edit_closed_group.xml @@ -4,8 +4,9 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> <item - android:title="@string/menu_apply_button" android:id="@+id/action_apply" - app:showAsAction="always|withText"/> + android:title="@string/menu_apply_button" + android:contentDescription="@string/AccessibilityId_apply_changes" + app:showAsAction="always|withText" /> </menu> diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 7353dbd1fd..ef49c99170 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@color/ic_launcher_background"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/> + <monochrome android:drawable="@drawable/ic_launcher_foreground"/> </adaptive-icon> \ No newline at end of file diff --git a/app/src/main/res/raw/lf_session_cert.pem b/app/src/main/res/raw/lf_session_cert.pem deleted file mode 100644 index 344a055433..0000000000 --- a/app/src/main/res/raw/lf_session_cert.pem +++ /dev/null @@ -1,24 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEEzCCAvugAwIBAgIUY9RQqbjhsQEkdeSgV9L0os9xZ7AwDQYJKoZIhvcNAQEL -BQAwfDELMAkGA1UEBhMCQVUxETAPBgNVBAgMCFZpY3RvcmlhMRIwEAYDVQQHDAlN -ZWxib3VybmUxJTAjBgNVBAoMHE94ZW4gUHJpdmFjeSBUZWNoIEZvdW5kYXRpb24x -HzAdBgNVBAMMFnB1YmxpYy5sb2tpLmZvdW5kYXRpb24wHhcNMjEwNDA3MDExMDMx -WhcNMjMwNDA3MDExMDMxWjB8MQswCQYDVQQGEwJBVTERMA8GA1UECAwIVmljdG9y -aWExEjAQBgNVBAcMCU1lbGJvdXJuZTElMCMGA1UECgwcT3hlbiBQcml2YWN5IFRl -Y2ggRm91bmRhdGlvbjEfMB0GA1UEAwwWcHVibGljLmxva2kuZm91bmRhdGlvbjCC -ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM5dBJSIR5+VNNUxUOo6FG0e -RmZteRqBt50KXGbOi2A23a6sa57pLFh9Yw3hmlWV+QCL7ipG1X4IC55OStgoesf+ -K65VwEMP6Mtq0sSJS3R5TiuV2ZSRdSZTVjUyRXVe5T4Aw6wXVTAbc/HsyS780tDh -GclfDHhonPhZpmTAnSbfMOS+BfOnBNvDxdto0kVh6k5nrGlkT4ECloulHTQF2lwJ -0D6IOtv9AJplPdg6s2c4dY7durOdvr3NNVfvn5PTeRvbEPqzZur4WUUKIPNGu6mY -PxImqd4eUsL0Vod4aAsTIx4YMmCTi0m9W6zJI6nXcK/6a+iiA3+NTNMzEA9gQhEC -AwEAAaOBjDCBiTAdBgNVHQ4EFgQU/zahokxLvvFUpbnM6z/pwS1KsvwwHwYDVR0j -BBgwFoAU/zahokxLvvFUpbnM6z/pwS1KsvwwDwYDVR0TAQH/BAUwAwEB/zAhBgNV -HREEGjAYghZwdWJsaWMubG9raS5mb3VuZGF0aW9uMBMGA1UdJQQMMAoGCCsGAQUF -BwMBMA0GCSqGSIb3DQEBCwUAA4IBAQBql+JvoqpaYrFFTOuDn08U+pdcd3GM7tbI -zRH5LU+YnIpp9aRheek+2COW8DXsIy/kUngETCMLmX6ZaUj/WdHnTDkB0KTgxSHv -ad3ZznKPKZ26qJOklr+0ZWj4J3jHbisSzql6mqq7R2Kp4ESwzwqxvkbykM5RUnmz -Go/3Ol7bpN/ZVwwEkGfD/5rRHf57E/gZn2pBO+zotlQgr7HKRsIXQ2hIXVQqWmPQ -lvfIwrwAZlfES7BARFnHOpyVQxV8uNcV5K5eXzuVFjHBqvq+BtyGhWkP9yKJCHS9 -OUXxch0rzRsH2C/kRVVhEk0pI3qlFiRC8pCJs98SNE9l69EQtG7I ------END CERTIFICATE----- diff --git a/app/src/main/res/raw/seed1.pem b/app/src/main/res/raw/seed1.pem new file mode 100644 index 0000000000..57199d80bf --- /dev/null +++ b/app/src/main/res/raw/seed1.pem @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEDTCCAvWgAwIBAgIUWk96HLAovn4uFSI057KhnMxqosowDQYJKoZIhvcNAQEL +BQAwejELMAkGA1UEBhMCQVUxETAPBgNVBAgMCFZpY3RvcmlhMRIwEAYDVQQHDAlN +ZWxib3VybmUxJTAjBgNVBAoMHE94ZW4gUHJpdmFjeSBUZWNoIEZvdW5kYXRpb24x +HTAbBgNVBAMMFHNlZWQxLmdldHNlc3Npb24ub3JnMB4XDTIzMDQwNTAxMjQzNVoX +DTMzMDQwNTAxMjQzNVowejELMAkGA1UEBhMCQVUxETAPBgNVBAgMCFZpY3Rvcmlh +MRIwEAYDVQQHDAlNZWxib3VybmUxJTAjBgNVBAoMHE94ZW4gUHJpdmFjeSBUZWNo +IEZvdW5kYXRpb24xHTAbBgNVBAMMFHNlZWQxLmdldHNlc3Npb24ub3JnMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2wlGkR2aDOHoizik4mqvWEwDPOQG +o/Afd/6VqKzo4BpNerVZQNgdMgdLTedZE4FRfetubonYu6iSYALK2iKoGsIlru1u +Q9dUl0abA9v+yg6duh1aHw8oS16JPL0zdq8QevJaTxd0MeDnx4eXfFjtv8L0xO4r +CRFH+H6ATcJy+zhVBcWLjiNPe6mGSHM4trx3hwJY6OuuWX5FkO0tMqj9aKJtJ+l0 +NArra0BZ9MaMwAFE7AxWwyD0jWIcSvwK06eap+6jBcZIr+cr7fPO5mAlT+CoGB68 +yUFwh1wglcVdNPoa1mbFQssCsCRa3MWgpzbMq+KregVzjVEtilwLFjx7FQIDAQAB +o4GKMIGHMB0GA1UdDgQWBBQ1XAjGKhyIU22mYdUEIlzlktogNzAfBgNVHSMEGDAW +gBQ1XAjGKhyIU22mYdUEIlzlktogNzAPBgNVHRMBAf8EBTADAQH/MB8GA1UdEQQY +MBaCFHNlZWQxLmdldHNlc3Npb24ub3JnMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA0G +CSqGSIb3DQEBCwUAA4IBAQC4PRiu4LyxK71Gk+f3dDvjinuE9F0XtAamKfRlLMEo +KxK8dtLrT8p62rME7QiigSv15AmSNyqAp751N/j0th1prOnxBoG38BXKLBDDClri +u91MR4h034G6LIYCiM99ldc8Q5a5WCKu9/9z6CtVxZcNlfe477d6lKHSwb3mQ581 +1Ui3RnpkkU1n4XULI+TW2n/Hb8gN6IyTHFB9y2jb4kdg7N7PZIN8FS3n3XGiup9r +b/Rujkuy7rFW78Q1BuHWrQPbJ3RU2CKh1j5o6mtcJFRqP1PfqWmbuaomam48s5hU +4JEiR9tyxP+ewl/bToFcet+5Lp9wRLxn0afm/3V00WyP +-----END CERTIFICATE----- diff --git a/app/src/main/res/raw/seed1cert.pem b/app/src/main/res/raw/seed1cert.pem deleted file mode 100644 index 7360d6fca0..0000000000 --- a/app/src/main/res/raw/seed1cert.pem +++ /dev/null @@ -1,25 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEITCCAwmgAwIBAgIUJsox1ZQPK/6iDsCC+MUJfNAlFuYwDQYJKoZIhvcNAQEL -BQAwgYAxCzAJBgNVBAYTAkFVMREwDwYDVQQIDAhWaWN0b3JpYTESMBAGA1UEBwwJ -TWVsYm91cm5lMSUwIwYDVQQKDBxPeGVuIFByaXZhY3kgVGVjaCBGb3VuZGF0aW9u -MSMwIQYDVQQDDBpzdG9yYWdlLnNlZWQxLmxva2kubmV0d29yazAeFw0yMTA0MDcw -MTE5MjZaFw0yMzA0MDcwMTE5MjZaMIGAMQswCQYDVQQGEwJBVTERMA8GA1UECAwI -VmljdG9yaWExEjAQBgNVBAcMCU1lbGJvdXJuZTElMCMGA1UECgwcT3hlbiBQcml2 -YWN5IFRlY2ggRm91bmRhdGlvbjEjMCEGA1UEAwwac3RvcmFnZS5zZWVkMS5sb2tp -Lm5ldHdvcmswggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtWH3Rz8Dd -kEmM7tcBWHrJ/G8drr/+qidboEVYzxpyRjszaDxKXVhx4eBBsAD5RuCWuTuZmM8k -TKEDLtf8xfb5SQ7YNX+346s9NXS5Poy4CIPASiW/QWXgIHFbVdv2hC+cKOP61OLM -OGnOxfig6tQyd6EaCkedpY1DvSa2lPnQSOwC/jXCx6Vboc0zTY5R2bHtNc9hjIFP -F4VClLAQSh2F4R1V9MH5KZMW+CCP6oaJY658W9JYXYRwlLrL2EFOVxHgcxq/6+fw -+axXK9OXJrGZjuA+hiz+L/uAOtE4WuxrSeuNMHSrMtM9QqVn4bBuMJ21mAzfNoMP -OIwgMT9DwUjVAgMBAAGjgZAwgY0wHQYDVR0OBBYEFOubJp9SoXIw+ONiWgkOaW8K -zI/TMB8GA1UdIwQYMBaAFOubJp9SoXIw+ONiWgkOaW8KzI/TMA8GA1UdEwEB/wQF -MAMBAf8wJQYDVR0RBB4wHIIac3RvcmFnZS5zZWVkMS5sb2tpLm5ldHdvcmswEwYD -VR0lBAwwCgYIKwYBBQUHAwEwDQYJKoZIhvcNAQELBQADggEBAIiHNhNrjYvwXVWs -gacx8T/dpqpu9GE3L17LotgQr4R+IYHpNtcmwOTdtWWFfUTr75OCs+c3DqgRKEoj -lnULOsVcalpAGIvW15/fmZWOf66Dpa4+ljDmAc3SOQiD0gGNtqblgI5zG1HF38QP -hjYRhCZ5CVeGOLucvQ8tVVwQvArPFIkBr0jH9jHVgRWEI2MeI3FsU2H93D4TfGln -N4SmmCfYBqygaaZBWkJEt0bYhn8uGHdU9UY9L2FPtfHVKkmFgO7cASGlvXS7B/TT -/8IgbtM3O8mZc2asmdQhGwoAKz93ryyCd8X2UZJg/IwCSCayOlYZWY2fR4OPQmmV -gxJsm+g= ------END CERTIFICATE----- diff --git a/app/src/main/res/raw/seed2.pem b/app/src/main/res/raw/seed2.pem new file mode 100644 index 0000000000..bf14073c23 --- /dev/null +++ b/app/src/main/res/raw/seed2.pem @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEDTCCAvWgAwIBAgIUXkVaUNO/G727mNeaiso9MjvBEm4wDQYJKoZIhvcNAQEL +BQAwejELMAkGA1UEBhMCQVUxETAPBgNVBAgMCFZpY3RvcmlhMRIwEAYDVQQHDAlN +ZWxib3VybmUxJTAjBgNVBAoMHE94ZW4gUHJpdmFjeSBUZWNoIEZvdW5kYXRpb24x +HTAbBgNVBAMMFHNlZWQyLmdldHNlc3Npb24ub3JnMB4XDTIzMDQwNTAxMjI0MloX +DTMzMDQwNTAxMjI0MlowejELMAkGA1UEBhMCQVUxETAPBgNVBAgMCFZpY3Rvcmlh +MRIwEAYDVQQHDAlNZWxib3VybmUxJTAjBgNVBAoMHE94ZW4gUHJpdmFjeSBUZWNo +IEZvdW5kYXRpb24xHTAbBgNVBAMMFHNlZWQyLmdldHNlc3Npb24ub3JnMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvT493tt1EWdyIa++X59ffrQt+ghK ++3Hv/guCPmR0FxPUeVnayoLbeKgbe8dduThh7nlmlYnpwbulvDnMF/rRpX51AZiT +A8UGktBzGXi17/D/X71EXGqlM41QZfVm5MCdQcghvbwO8MP0nWmbV4DdiNYAwSNh +fpGMEiblCvKtGN71clTkOW+8Moq4eOxT9tKIlOv97uvkUS21NgmSzsj453hrb6oj +XR3rtW264zn99+Gv83rDE1jk0qfDjxCkaUb0BvRDREc+1q3p8GZ6euEFBM3AcXe7 +Yl0qbJgIXd5I+W5nMJJCyJHPTxQNvS+uJqL4kLvdwQRFAkwEM+t9GCH1PQIDAQAB +o4GKMIGHMB0GA1UdDgQWBBQOdqxllTHj+fmGjmdgIXBl+k0PRDAfBgNVHSMEGDAW +gBQOdqxllTHj+fmGjmdgIXBl+k0PRDAPBgNVHRMBAf8EBTADAQH/MB8GA1UdEQQY +MBaCFHNlZWQyLmdldHNlc3Npb24ub3JnMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA0G +CSqGSIb3DQEBCwUAA4IBAQBkmmX+mopdnhzQC5b5rgbU7wVhlDaG7eJCRgUvqkYm +Pbv6XFfvtshykhw2BjSyQetofJaBh5KOR7g0MGRSn3AqRPBeEpXfkBI9urhqFwBF +F5atmp1rTCeHuAS6w4mL6rmj7wHl2CRSom7czRdUCNM+Tu1iK6xOrtOLwQ1H1ps1 +KK3siJb3W0eKykHnheQPn77RulVBNLz1yedEUTVkkuVhzSUj5yc8tiwrcagwWX6m +BlfVCJgsBbrJ754rg0AJ0k59wriRamimcUIBvKIo3g3UhJHDI8bt4+SvsRYkSmbi +rzVthAlJjSlRA28X/OLnknWcgEdkGhu0F1tkBtVjIQXd +-----END CERTIFICATE----- diff --git a/app/src/main/res/raw/seed3.pem b/app/src/main/res/raw/seed3.pem new file mode 100644 index 0000000000..6939129f8f --- /dev/null +++ b/app/src/main/res/raw/seed3.pem @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEDTCCAvWgAwIBAgIUTz5rHKUe+VA9IM6vY6QACc0ORFkwDQYJKoZIhvcNAQEL +BQAwejELMAkGA1UEBhMCQVUxETAPBgNVBAgMCFZpY3RvcmlhMRIwEAYDVQQHDAlN +ZWxib3VybmUxJTAjBgNVBAoMHE94ZW4gUHJpdmFjeSBUZWNoIEZvdW5kYXRpb24x +HTAbBgNVBAMMFHNlZWQzLmdldHNlc3Npb24ub3JnMB4XDTIzMDQwNTAxMjYzMVoX +DTMzMDQwNTAxMjYzMVowejELMAkGA1UEBhMCQVUxETAPBgNVBAgMCFZpY3Rvcmlh +MRIwEAYDVQQHDAlNZWxib3VybmUxJTAjBgNVBAoMHE94ZW4gUHJpdmFjeSBUZWNo +IEZvdW5kYXRpb24xHTAbBgNVBAMMFHNlZWQzLmdldHNlc3Npb24ub3JnMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6FgxIk9KmYISL5fk7BLaGAW6lBx8 +b4VL3DjlyrFMz7ZhSbcUcavWyyYB+iJxBRhfQGJ7vbwJZ1AwVJisjDFdiLcWzTF8 +gzZ7LVXH8qlVnqcx0gksrWYFnG3Y2WJrxEBFdD29lP7LVN3xLQdplMitOciqg5jN +oRjtwGo+wzaMW6WNPzgTvxLzPce9Rl3oN4tSK7qlA9VtsyHwOWBMcogv9LC9IUFZ +2yu0RdcxPdlwLwywYtSRt/W87KbAWTcYY1DfN2VA68p9Cip7/dPOokRduMh1peux +swmIybpC/wz/Ql6J6scSOjDUp/2UsIdYIvyP/Dibi4nHRmD+oz9kb+J3AQIDAQAB +o4GKMIGHMB0GA1UdDgQWBBSQAFetDPIzVg9rfgOI7bfaeEHd8TAfBgNVHSMEGDAW +gBSQAFetDPIzVg9rfgOI7bfaeEHd8TAPBgNVHRMBAf8EBTADAQH/MB8GA1UdEQQY +MBaCFHNlZWQzLmdldHNlc3Npb24ub3JnMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA0G +CSqGSIb3DQEBCwUAA4IBAQCiBNdbKNSHyCZJKvC/V+pHy9E/igwvih2GQ5bNZJFA +daOiKBgaADxaxB4lhtzasr2LdgZdLrn0oONw+wYaui9Z12Yfdr9oWuOgktn8HKLY +oKkJc5EcMYFsd00FnnFcO2U8lQoL6PB/tdcEmpOfqtvShpNhp8SbadSNiqlttvtV +1dqvqSBiRdQm1kz2b8hA6GR6SPzSKlSuwI0J+ZcXEi232EJFbgJ3ESHFVHrhUZro +8A16/WDvZOMWCjOqJsFBw15WzosW9kyNwBtZinXVO3LW/7tVl08PDcarpH4IWjd0 +LDpU7zGjcD/A19tfdfMFTOmETuq40I8xxtlR2NENFOAL +-----END CERTIFICATE----- diff --git a/app/src/main/res/raw/seed3cert.pem b/app/src/main/res/raw/seed3cert.pem deleted file mode 100644 index 92574b769b..0000000000 --- a/app/src/main/res/raw/seed3cert.pem +++ /dev/null @@ -1,25 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEITCCAwmgAwIBAgIUc486Dy9Y00bUFfDeYmJIgSS5xREwDQYJKoZIhvcNAQEL -BQAwgYAxCzAJBgNVBAYTAkFVMREwDwYDVQQIDAhWaWN0b3JpYTESMBAGA1UEBwwJ -TWVsYm91cm5lMSUwIwYDVQQKDBxPeGVuIFByaXZhY3kgVGVjaCBGb3VuZGF0aW9u -MSMwIQYDVQQDDBpzdG9yYWdlLnNlZWQzLmxva2kubmV0d29yazAeFw0yMTA0MDcw -MTIwNTJaFw0yMzA0MDcwMTIwNTJaMIGAMQswCQYDVQQGEwJBVTERMA8GA1UECAwI -VmljdG9yaWExEjAQBgNVBAcMCU1lbGJvdXJuZTElMCMGA1UECgwcT3hlbiBQcml2 -YWN5IFRlY2ggRm91bmRhdGlvbjEjMCEGA1UEAwwac3RvcmFnZS5zZWVkMy5sb2tp -Lm5ldHdvcmswggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtokMlsFzf -piYeD0EVNikMyvjltpF6fUEde9NOVrTtNTQT6kkDk+/0HF5LYgPaatv6v7fpUQHi -kIwd6F0LTRGeWDFdsaWMdtlR1n/GxLPrOROsE8dcLt6GLavPf9rDabgva93m/JD6 -XW+Ne+MPEwqS8dAmFGhZd0gju6AtKFoSHnIf5pSQN6fSZUF/JQtHLVprAKKWKDiS -ZwmWbmrZR2aofLD/VRpetabajnZlv9EeWloQwvUsw1C1hkAmmtFeeXtg7ePwrOzo -6CnmcUJwOmi+LWqQV4A+58RZPFKaZoC5pzaKd0OYB8eZ8HB1F41UjGJgheX5Cyl4 -+amfF3l8dSq1AgMBAAGjgZAwgY0wHQYDVR0OBBYEFM9VSq4pGydjtX92Beul4+ml -jBKtMB8GA1UdIwQYMBaAFM9VSq4pGydjtX92Beul4+mljBKtMA8GA1UdEwEB/wQF -MAMBAf8wJQYDVR0RBB4wHIIac3RvcmFnZS5zZWVkMy5sb2tpLm5ldHdvcmswEwYD -VR0lBAwwCgYIKwYBBQUHAwEwDQYJKoZIhvcNAQELBQADggEBAAYxmhhkcKE1n6g1 -JqOa3UCBo4EfbqY5+FDZ0FVqv/cwemwVpKLbe6luRIS8poomdPCyMOS45V7wN3H9 -cFpfJ1TW19ydPVKmCXrl29ngmnY1q7YDwE/4qi3VK/UiqDkTHMKWjVPkenOyi8u6 -VVQANXSnKrn6GtigNFjGyD38O+j7AUSXBtXOJczaoF6r6BWgwQZ2WmgjuwvKTWSN -4r8uObERoAQYVaeXfgdr4e9X/JdskBDaLFfoW/rrSozHB4FqVNFW96k+aIUgRa5p -9kv115QcBPCSh9qOyTHij4tswS6SyOFaiKrNC4hgHQXP4QgioKmtsR/2Y+qJ6ddH -6oo+4QU= ------END CERTIFICATE----- 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-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 9d58ab9178..3aeeba0550 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -1,10 +1,10 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="app_name">Session</string> <string name="yes">Oui</string> <string name="no">Non</string> <string name="delete">Supprimer</string> <string name="ban">Bannir</string> + <string name="please_wait">Veuillez patienter…</string> <string name="save">Enregistrer</string> <string name="note_to_self">Note à mon intention</string> <string name="version_s">Version %s</string> @@ -19,7 +19,7 @@ </plurals> <string name="ApplicationPreferencesActivity_delete_all_old_messages_now">Supprimer tous les anciens messages maintenant ?</string> <plurals name="ApplicationPreferencesActivity_this_will_immediately_trim_all_conversations_to_the_d_most_recent_messages"> - <item quantity="one">Cela va immédiatement réduire toutes les conversations pour qu’il ne reste que le message le plus récent.</item> + <item quantity="one">Cela réduira immédiatement toutes les conversations au message le plus récent.</item> <item quantity="other">Cela réduira immédiatement toutes les conversations aux %d messages les plus récents.</item> </plurals> <string name="ApplicationPreferencesActivity_delete">Supprimer</string> @@ -63,6 +63,7 @@ <string name="ConversationActivity_muted_until_date">Son désactivé jusqu\'à %1$s</string> <string name="ConversationActivity_muted_forever">En sourdine</string> <string name="ConversationActivity_member_count">%1$d membres</string> + <string name="ConversationActivity_active_member_count">%1$d membres actifs</string> <string name="ConversationActivity_open_group_guidelines">Règles de la communauté</string> <string name="ConversationActivity_invalid_recipient">Le destinataire est invalide !</string> <string name="ConversationActivity_added_to_home_screen">Ajouté à l’écran d’accueil</string> @@ -82,7 +83,9 @@ <string name="ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage">Session a besoin d\'un accès au stockage pour envoyer des photos et des vidéos.</string> <string name="ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video">Session a besoin de l’autorisation Appareil photo afin de prendre des photos ou des vidéos, mais elle a été refusée définitivement. Veuillez accéder au menu des paramètres des applis, sélectionner Autorisations et activer Appareil photo.</string> <string name="ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video">Session a besoin de l’autorisation Appareil photo pour prendre des photos ou des vidéos</string> - <string name="ConversationActivity_search_position">%1$d de %2$d</string> + <string name="ConversationActivity_search_position">%1$d sur %2$d</string> + <string name="ConversationActivity_call_title">Autorisations d\'appel requises</string> + <string name="ConversationActivity_call_prompt">Vous pouvez activer la permission \"Appels vocaux et vidéo\" dans les paramètres de confidentialité.</string> <!-- ConversationFragment --> <plurals name="ConversationFragment_delete_selected_messages"> <item quantity="one">Supprimer le message sélectionné ?</item> @@ -99,13 +102,17 @@ <item quantity="other">L’enregistrement des %1$d médias dans la mémoire permettra à n’importe quelles autres applis de votre appareil d’y accéder.\n\nContinuer ?</item> </plurals> <plurals name="ConversationFragment_error_while_saving_attachments_to_sd_card"> - <item quantity="one">Erreur d’enregistrement de la pièce jointe dans la mémoire !</item> + <item quantity="one">Erreur lors de l’enregistrement de la pièce jointe dans la mémoire !</item> <item quantity="other">Erreur d’enregistrement des pièces jointes dans la mémoire !</item> </plurals> <plurals name="ConversationFragment_saving_n_attachments"> <item quantity="one">Enregistrement de la pièce jointe</item> <item quantity="other">Enregistrement de %1$d pièces jointes</item> </plurals> + <plurals name="ConversationFragment_saving_n_attachments_to_sd_card"> + <item quantity="one">Enregistrement de la pièce jointe dans la mémoire…</item> + <item quantity="other">Enregistrement de %1$d pièces jointes dans la mémoire…</item> + </plurals> <!-- CreateProfileActivity --> <string name="CreateProfileActivity_profile_photo">Photo de profil</string> <!-- CustomDefaultPreference --> @@ -156,7 +163,7 @@ <!-- MediaPickerActivity --> <string name="MediaPickerActivity_send_to">Envoyer à %s</string> <!-- MediaSendActivity --> - <string name="MediaSendActivity_add_a_caption">Ajouter un légende…</string> + <string name="MediaSendActivity_add_a_caption">Ajouter une légende...</string> <string name="MediaSendActivity_an_item_was_removed_because_it_exceeded_the_size_limit">Un élément a été supprimé, car il dépassait la taille limite</string> <string name="MediaSendActivity_camera_unavailable">L’appareil photo n’est pas disponible</string> <string name="MediaSendActivity_message_to_s">Message à %s</string> @@ -191,6 +198,7 @@ <string name="Slide_video">Vidéo</string> <!-- SmsMessageRecord --> <string name="SmsMessageRecord_received_corrupted_key_exchange_message">Vous avez reçu un message d’échange de clés corrompu !</string> + <string name="SmsMessageRecord_received_key_exchange_message_for_invalid_protocol_version"> Le message d\'échange de clé reçu est pour une version du protocole invalide. </string> <string name="SmsMessageRecord_received_message_with_new_safety_number_tap_to_process">Vous avez reçu un message avec un nouveau numéro de sécurité. Touchez pour le traiter et l’afficher.</string> <string name="SmsMessageRecord_secure_session_reset">Vous avez réinitialisé la session sécurisée.</string> <string name="SmsMessageRecord_secure_session_reset_s">%s a réinitialisé la session sécurisée.</string> @@ -201,7 +209,7 @@ <string name="ThreadRecord_secure_session_reset">La session sécurisée a été réinitialisée.</string> <string name="ThreadRecord_draft">Brouillon :</string> <string name="ThreadRecord_called">Vous avez appelé</string> - <string name="ThreadRecord_called_you">Vous a appelé</string> + <string name="ThreadRecord_called_you">Vous a appelé·e</string> <string name="ThreadRecord_missed_call">Appel manqué</string> <string name="ThreadRecord_media_message">Message multimédia</string> <string name="ThreadRecord_s_is_on_signal">%s est sur Session !</string> @@ -304,7 +312,7 @@ <string name="conversation_activity__send">Envoyer</string> <string name="conversation_activity__compose_description">Rédaction d’un message</string> <string name="conversation_activity__emoji_toggle_description">Afficher, masquer le clavier des émojis</string> - <string name="conversation_activity__attachment_thumbnail">Imagette de pièces jointes</string> + <string name="conversation_activity__attachment_thumbnail">Vignette de pièce jointe</string> <string name="conversation_activity__quick_attachment_drawer_toggle_camera_description">Afficher, masquer le tiroir permettant de lancer l’appareil photo à basse résolution</string> <string name="conversation_activity__quick_attachment_drawer_record_and_send_audio_description">Enregistrer et envoyer une pièce jointe audio</string> <string name="conversation_activity__quick_attachment_drawer_lock_record_description">Verrouiller l’enregistrement de pièces jointes audio</string> @@ -378,9 +386,9 @@ <string name="arrays__settings_default">Valeur par défaut</string> <string name="arrays__enabled">Activé</string> <string name="arrays__disabled">Désactivé</string> - <string name="arrays__name_and_message">Nom et message</string> - <string name="arrays__name_only">Nom seulement</string> - <string name="arrays__no_name_or_message">Aucun nom ni message</string> + <string name="arrays__name_and_message">Nom et Contenu</string> + <string name="arrays__name_only">Nom uniquement</string> + <string name="arrays__no_name_or_message">Aucun nom ni contenu</string> <string name="arrays__images">Images</string> <string name="arrays__audio">Son</string> <string name="arrays__video">Vidéo</string> @@ -398,9 +406,14 @@ <item quantity="other">%d heures</item> </plurals> <!-- preferences.xml --> - <string name="preferences__pref_enter_sends_title">La touche Entrée envoie</string> - <string name="preferences__send_link_previews">Envoyer des aperçus de liens</string> - <string name="preferences__previews_are_supported_for">Les aperçus sont pris en charge pour les liens Imgur, Instagram, Pinterest, Reddit et YouTube</string> + <string name="preferences__pref_enter_sends_title">Envoyer avec bouton Entrée</string> + <string name="preferences__pref_enter_sends_summary">Appuyer sur la touche Entrée enverra un message au lieu de commencer une nouvelle ligne.</string> + <string name="preferences__send_link_previews">Envoyer les aperçus des liens</string> + <string name="preferences__link_previews">Aperçus des liens</string> + <string name="preferences__link_previews_summary">Générer les aperçus des liens pour les URLs supportés.</string> + <string name="preferences__pref_autoplay_audio_category">Messages audio</string> + <string name="preferences__pref_autoplay_audio_title">Lire automatiquement les messages audios</string> + <string name="preferences__pref_autoplay_audio_summary">Lire automatiquement les messages audio consécutifs.</string> <string name="preferences__screen_security">Sécurité de l’écran</string> <string name="preferences__disable_screen_security_to_allow_screen_shots">Bloquer les captures d’écran dans la liste des récents et dans l’appli</string> <string name="preferences__notifications">Notifications</string> @@ -408,6 +421,7 @@ <string name="preferences__led_color_unknown">Inconnue</string> <string name="preferences__pref_led_blink_title">Rythme de clignotement de la DEL</string> <string name="preferences__sound">Son</string> + <string name="preferences__in_app_sounds">Son à l\'ouverture de l\'application</string> <string name="preferences__silent">Silencieux</string> <string name="preferences__repeat_alerts">Répéter les alertes</string> <string name="preferences__never">Jamais</string> @@ -436,18 +450,27 @@ <string name="preferences__default">Valeur par défaut</string> <string name="preferences__incognito_keyboard">Clavier incognito</string> <string name="preferences__read_receipts">Accusés de lecture</string> + <string name="preferences__read_receipts_summary">Envoyer des accusés de lecture dans les conversations individuelles.</string> <string name="preferences__typing_indicators">Indicateurs de saisie</string> - <string name="preferences__if_typing_indicators_are_disabled_you_wont_be_able_to_see_typing_indicators">Si les indicateurs de saisie sont désactivés, vous ne serez pas en mesure de voir les indicateurs de saisie des autres.</string> + <string name="preferences__typing_indicators_summary">Voir et envoyer les indicateurs de saisie dans les conversations un à un.</string> <string name="preferences__request_keyboard_to_disable_personalized_learning">Demander au clavier de désactiver l’apprentissage personnalisé</string> <string name="preferences__light_theme">Clair</string> <string name="preferences__dark_theme">Sombre</string> <string name="preferences_chats__message_trimming">Élagage des messages</string> + <string name="preferences_chats__message_trimming_title">Raccourcir les communautés</string> + <string name="preferences_chats__message_trimming_summary">Supprimer des messages de plus de 6 mois dans des communautés qui ont plus de 2 000 messages.</string> <string name="preferences_advanced__use_system_emoji">Utiliser les émojis du système</string> <string name="preferences_advanced__disable_signal_built_in_emoji_support">Désactiver la prise en charge des émojis intégrés à Session</string> + <string name="preferences_app_protection__screen_security">Sécurité d\'écran</string> <string name="preferences_chats__chats">Conversations</string> <string name="preferences_notifications__messages">Messages</string> <string name="preferences_notifications__in_chat_sounds">Sons des conversations</string> + <string name="preferences_notifications__content">Contenu de la notification</string> + <string name="preferences_notifications__content_message">Afficher :</string> + <string name="preferences_notifications__summary">Informations affichées dans les notifications.</string> <string name="preferences_notifications__priority">Priorité</string> + <string name="preferences_app_protection__screenshot_notifications">Notifications de capture d\'écran</string> + <string name="preferences_app_protected__screenshot_notifications_summary">Recevoir une notification lorsqu\'un contact prend une capture d\'écran d\'une conversation individuelle.</string> <!-- **************************************** --> <!-- menus --> <!-- **************************************** --> @@ -460,7 +483,10 @@ <string name="conversation_context__menu_ban_user">Bannir l\'utilisateur</string> <string name="conversation_context__menu_ban_and_delete_all">Bannir et supprimer tout</string> <string name="conversation_context__menu_resend_message">Renvoyer le message</string> + <string name="conversation_context__menu_reply">Répondre</string> <string name="conversation_context__menu_reply_to_message">Répondre au message</string> + <string name="conversation_context__menu_call">Appeler</string> + <string name="conversation_context__menu_select">Sélectionner</string> <!-- conversation_context_image --> <string name="conversation_context_image__save_attachment">Enregistrer la pièce jointe</string> <!-- conversation_expiring_off --> @@ -513,8 +539,8 @@ <string name="LocalBackupJob_creating_backup">Création de la sauvegarde…</string> <string name="ProgressPreference_d_messages_so_far">%d messages pour l’instant</string> <string name="BackupUtil_never">Jamais</string> - <string name="preferences_app_protection__screen_lock">Verrouillage de l’écran</string> - <string name="preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint">Verrouiller l’accès à Session avec le verrouillage de l’écran d’Android ou une empreinte</string> + <string name="preferences_app_protection__screen_lock">Verrouiller Session</string> + <string name="preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint">Nécessite une empreinte digitale, un code PIN, un schéma ou un mot de passe pour déverrouiller Session.</string> <string name="preferences_app_protection__screen_lock_inactivity_timeout">Délai d’inactivité avant verrouillage de l’écran</string> <string name="AppProtectionPreferenceFragment_none">Aucune</string> <!-- Conversation activity --> @@ -522,6 +548,7 @@ <!-- Session --> <string name="continue_2">Continuer</string> <string name="copy">Copier</string> + <string name="close">Fermer</string> <string name="invalid_url">URL non valide</string> <string name="copied_to_clipboard">Copié dans le presse-papier</string> <string name="next">Suivant</string> @@ -573,12 +600,12 @@ <string name="activity_path_resolving_progress">Contact en cours…</string> <string name="activity_create_private_chat_title">Nouvelle Session</string> <string name="activity_create_private_chat_enter_session_id_tab_title">Saisir un Session ID</string> - <string name="activity_create_private_chat_scan_qr_code_tab_title">Scanner un Code QR</string> - <string name="activity_create_private_chat_scan_qr_code_explanation">Scannez le code QR d\'un utilisateur pour démarrer une session. Les codes QR peuvent se trouver en touchant l\'icône du code QR dans les paramètres du compte.</string> + <string name="activity_create_private_chat_scan_qr_code_tab_title">Scanner un QR Code</string> + <string name="activity_create_private_chat_scan_qr_code_explanation">Scannez le QR code d\'un utilisateur pour démarrer une session. Les QR codes peuvent se trouver en touchant l\'icône du QR code dans les paramètres du compte.</string> <string name="fragment_enter_public_key_edit_text_hint">Entrer un Session ID ou un nom ONS</string> <string name="fragment_enter_public_key_explanation">Les utilisateurs peuvent partager leur Session ID depuis les paramètres du compte ou en utilisant le code QR.</string> <string name="fragment_enter_public_key_error_message">Veuillez vérifier le Session ID ou le nom ONS et réessayer.</string> - <string name="fragment_scan_qr_code_camera_access_explanation">Session a besoin d\'accéder à l\'appareil photo pour scanner les codes QR</string> + <string name="fragment_scan_qr_code_camera_access_explanation">Session a besoin d\'accéder à l\'appareil photo pour scanner les QR codes</string> <string name="fragment_scan_qr_code_grant_camera_access_button_title">Autoriser l\'accès</string> <string name="activity_create_closed_group_title">Nouveau groupe privé</string> <string name="activity_create_closed_group_edit_text_hint">Saisissez un nom de groupe</string> @@ -591,7 +618,7 @@ <string name="activity_join_public_chat_title">Joindre un groupe public</string> <string name="activity_join_public_chat_error">Impossible de rejoindre le groupe</string> <string name="activity_join_public_chat_enter_group_url_tab_title">URL du groupe public</string> - <string name="activity_join_public_chat_scan_qr_code_tab_title">Scannez le code QR</string> + <string name="activity_join_public_chat_scan_qr_code_tab_title">Scanner le QR Code</string> <string name="activity_join_public_chat_scan_qr_code_explanation">Scannez le code QR du groupe public que vous souhaitez rejoindre</string> <string name="fragment_enter_chat_url_edit_text_hint">Saisissez une URL de groupe public</string> <string name="activity_settings_title">Paramètres</string> @@ -600,6 +627,7 @@ <string name="activity_settings_display_name_too_long_error">Veuillez choisir un nom d\'utilisateur plus court</string> <string name="activity_settings_privacy_button_title">Confidentialité</string> <string name="activity_settings_notifications_button_title">Notifications</string> + <string name="activity_settings_message_requests_button_title">Demandes de message</string> <string name="activity_settings_chats_button_title">Conversations</string> <string name="activity_settings_devices_button_title">Appareils reliés</string> <string name="activity_settings_invite_button_title">Inviter un ami</string> @@ -612,25 +640,39 @@ <string name="activity_notification_settings_style_section_title">Style de notification</string> <string name="activity_notification_settings_content_section_title">Contenu de notification</string> <string name="activity_privacy_settings_title">Confidentialité</string> + <string name="activity_conversations_settings_title">Conversations</string> + <string name="activity_help_settings_title">Aide</string> + <string name="activity_help_settings__report_bug_title">Signaler un bug</string> + <string name="activity_help_settings__report_bug_summary">Exportez vos logs, puis télécharger le fichier au service d\'aide de Session.</string> + <string name="activity_help_settings__translate_session">Traduire Session</string> + <string name="activity_help_settings__feedback">Nous aimerions avoir votre avis</string> + <string name="activity_help_settings__faq">FAQ</string> + <string name="activity_help_settings__support">Assistance</string> + <string name="activity_help_settings__export_logs">Exporter les journaux</string> <string name="preferences_notifications_strategy_category_title">Stratégie de notification</string> <string name="preferences_notifications_strategy_category_fast_mode_title">Utiliser le Mode Rapide</string> <string name="preferences_notifications_strategy_category_fast_mode_summary">Vous serez averti de nouveaux messages de manière fiable et immédiate en utilisant les serveurs de notification de Google.</string> <string name="fragment_device_list_bottom_sheet_change_name_button_title">Modifier le nom</string> <string name="fragment_device_list_bottom_sheet_unlink_device_button_title">Déconnecter l\'appareil</string> <string name="dialog_seed_title">Votre phrase de récupération</string> - <string name="dialog_seed_explanation">Ceci est votre phrase de récupération. Elle vous permet de restaurer ou migrer votre Session ID vers un nouvel appareil.</string> + <string name="dialog_seed_explanation">Vous pouvez utiliser votre phrase de récupération pour restaurer votre compte ou relier un appareil.</string> <string name="dialog_clear_all_data_title">Effacer toutes les données</string> <string name="dialog_clear_all_data_explanation">Cela supprimera définitivement vos messages, vos sessions et vos contacts.</string> <string name="dialog_clear_all_data_network_explanation">Souhaitez-vous effacer seulement cet appareil ou supprimer l\'ensemble de votre compte ?</string> + <string name="dialog_clear_all_data_message">Cela supprimera définitivement vos messages, sessions et contacts. Voulez-vous uniquement effacer cet appareil ou supprimer l\'intégralité de votre compte ?</string> + <string name="dialog_clear_all_data_clear_device_only">Effacer l\'appareil uniquement</string> + <string name="dialog_clear_all_data_clear_device_and_network">Effacer l\'appareil et le réseau</string> + <string name="dialog_clear_all_data_clear_device_and_network_confirmation">Êtes-vous sûr de vouloir supprimer vos données du réseau ? Si vous continuez, vous ne pourrez pas restaurer vos messages ou vos contacts.</string> + <string name="dialog_clear_all_data_clear">Effacer</string> <string name="dialog_clear_all_data_local_only">Effacer seulement</string> <string name="dialog_clear_all_data_clear_network">Compte complet</string> - <string name="activity_qr_code_title">Code QR</string> - <string name="activity_qr_code_view_my_qr_code_tab_title">Afficher mon code QR</string> - <string name="activity_qr_code_view_scan_qr_code_tab_title">Scanner le code QR</string> - <string name="activity_qr_code_view_scan_qr_code_explanation">Scannez le code QR d\'un autre utilisateur pour démarrer une session</string> + <string name="activity_qr_code_title">QR Code</string> + <string name="activity_qr_code_view_my_qr_code_tab_title">Afficher mon QR code</string> + <string name="activity_qr_code_view_scan_qr_code_tab_title">Scanner le QR Code</string> + <string name="activity_qr_code_view_scan_qr_code_explanation">Scannez le QR code d\'un autre utilisateur pour démarrer une session</string> <string name="fragment_view_my_qr_code_title">Scannez-moi</string> - <string name="fragment_view_my_qr_code_explanation">Ceci est votre code QR. Les autres utilisateurs peuvent le scanner pour démarrer une session avec vous.</string> - <string name="fragment_view_my_qr_code_share_title">Partager le code QR</string> + <string name="fragment_view_my_qr_code_explanation">Ceci est votre QR code. Les autres utilisateurs peuvent le scanner pour démarrer une session avec vous.</string> + <string name="fragment_view_my_qr_code_share_title">Partager le QR code</string> <string name="fragment_contact_selection_contacts_title">Contacts</string> <string name="fragment_contact_selection_closed_groups_title">Groupes privés</string> <string name="fragment_contact_selection_open_groups_title">Groupes publics</string> @@ -664,8 +706,8 @@ <string name="activity_link_device_skip_prompt">Cela prend un certain temps, voulez-vous passer ?</string> <string name="activity_link_device_link_device">Relier un appareil</string> <string name="activity_link_device_recovery_phrase">Phrase de récupération</string> - <string name="activity_link_device_scan_qr_code">Scannez le code QR</string> - <string name="activity_link_device_qr_message">Allez dans Paramètres → Phrase de récupération sur votre autre appareil pour afficher votre code QR.</string> + <string name="activity_link_device_scan_qr_code">Scanner le QR Code</string> + <string name="activity_link_device_qr_message">Allez dans Paramètres → Phrase de récupération sur votre autre appareil pour afficher votre QR code.</string> <string name="activity_join_public_chat_join_rooms">Ou rejoignez l\'un(e) de ceux-ci…</string> <string name="activity_pn_mode_message_notifications">Notifications de message</string> <string name="activity_pn_mode_explanation">Session peut vous avertir de la présence de nouveaux messages de deux façons.</string> @@ -694,6 +736,7 @@ <string name="dialog_download_explanation">Êtes-vous sûr de vouloir télécharger le média envoyé par %s ?</string> <string name="dialog_download_button_title">Télécharger</string> <string name="activity_conversation_blocked_banner_text">%s est bloqué. Débloquer ?</string> + <string name="activity_conversation_block_user">Bloquer l\'utilisateur</string> <string name="activity_conversation_attachment_prep_failed">La préparation de la pièce jointe pour l\'envoi a échoué.</string> <string name="media">Médias</string> <string name="UntrustedAttachmentView_download_attachment">Touchez pour télécharger %s</string> @@ -711,6 +754,116 @@ <string name="activity_settings_support">Journal de débogage</string> <string name="dialog_share_logs_title">Partager les logs</string> <string name="dialog_share_logs_explanation">Voulez-vous exporter les logs de votre application pour pouvoir partager pour le dépannage ?</string> - <string name="conversation_pin">Code pin</string> + <string name="conversation_pin">Épingler</string> <string name="conversation_unpin">Désépingler</string> + <string name="mark_all_as_read">Tout marquer comme lu</string> + <string name="global_search_contacts_groups">Contacts et Groupes</string> + <string name="global_search_messages">Messages</string> + <string name="activity_message_requests_title">Demandes de message</string> + <string name="message_requests_send_notice">Envoyer un message à cet utilisateur acceptera automatiquement sa demande de message et révélera votre ID de session.</string> + <string name="accept">Accepter</string> + <string name="decline">Refuser</string> + <string name="message_requests_clear_all">Effacer tout</string> + <string name="message_requests_decline_message">Êtes-vous sûr de vouloir refuser cette demande de message ?</string> + <string name="message_requests_block_message">Êtes-vous sûr de vouloir supprimer cette demande de message ?</string> + <string name="message_requests_deleted">Demande de message supprimée</string> + <string name="message_requests_clear_all_message">Êtes-vous sûr de vouloir supprimer toutes les demandes de message ?</string> + <string name="message_requests_cleared">Demandes de message supprimées</string> + <string name="message_requests_accepted">Votre demande de message a été acceptée.</string> + <string name="message_requests_pending">Votre demande de message est en attente.</string> + <string name="message_request_empty_state_message">Aucune demande de message en attente</string> + <string name="NewConversationButton_SessionTooltip">Message privé</string> + <string name="NewConversationButton_ClosedGroupTooltip">Groupes privés</string> + <string name="NewConversationButton_OpenGroupTooltip">Groupe public</string> + <string name="message_requests_notification">Vous avez une nouvelle demande de message</string> + <string name="CallNotificationBuilder_connecting">Connexion…</string> + <string name="NotificationBarManager__incoming_signal_call">Appel entrant</string> + <string name="NotificationBarManager__deny_call">Refuser l’appel</string> + <string name="NotificationBarManager__answer_call">Répondre à l’appel</string> + <string name="NotificationBarManager_call_in_progress">Appel en cours</string> + <string name="NotificationBarManager__cancel_call">Annuler l’appel</string> + <string name="NotificationBarManager__establishing_signal_call">Établissement de l\'appel</string> + <string name="NotificationBarManager__end_call">Raccrocher</string> + <string name="accept_call">Accepter l\'appel</string> + <string name="decline_call">Refuser l\'appel</string> + <string name="preferences__voice_video_calls">Appels vocaux et vidéos</string> + <string name="preferences__calls_beta">Appels (Bêta)</string> + <string name="preferences__allow_access_voice_video">Active les appels vocaux et vidéo vers et depuis d\'autres utilisateurs.</string> + <string name="dialog_voice_video_title">Appels vocaux / vidéo</string> + <string name="dialog_voice_video_message">La version actuelle des appels vocaux/vidéo exposera votre adresse IP aux serveurs de la Fondation Oxen et aux utilisateurs appelés</string> + <string name="CallNotificationBuilder_first_call_title">Appel Manqué</string> + <string name="CallNotificationBuilder_first_call_message">Vous avez manqué un appel car vous devez activer la permission « Appels vocaux et vidéo » dans les paramètres de confidentialité.</string> + <string name="WebRtcCallActivity_Session_Call">Appel Session</string> + <string name="WebRtcCallActivity_Reconnecting">Reconnexion…</string> + <string name="CallNotificationBuilder_system_notification_title">Notifications</string> + <string name="CallNotificationBuilder_system_notification_message">Les notifications désactivées vous empêcheront de recevoir des appels, aller dans les paramètres de notification de session?</string> + <string name="dismiss">Rejeter</string> + <string name="activity_settings_conversations_button_title">Conversations</string> + <string name="activity_settings_message_appearance_button_title">Apparence</string> + <string name="activity_settings_help_button">Aide</string> + <string name="activity_appearance_themes_category">Thèmes</string> + <string name="ocean_dark_theme_name">Océan sombre</string> + <string name="classic_dark_theme_name">Sombre classique</string> + <string name="ocean_light_theme_name">Océan lumineux</string> + <string name="classic_light_theme_name">Clair classique</string> + <string name="activity_appearance_primary_color_category">Couleur principale</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__this_message">Ce message</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__recently_used">Fréquemment Utilisés</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__smileys_and_people">Émoticônes et personnes</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__nature" comment="Heading for an emoji list's category">Nature</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__food" comment="Heading for an emoji list's category">Nourriture</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__activities">Activités</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__places" comment="Heading for an emoji list's category">Voyage</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__objects">Objets</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__symbols">Symboles</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__flags">Drapeaux</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__emoticons">Emoticônes</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__no_results_found">Aucun résultat trouvé</string> + <!-- ReactionsBottomSheetDialogFragment --> + <string name="ReactionsBottomSheetDialogFragment_all">Tous · %1$d</string> + <!-- ReactionsConversationView --> + <string name="ReactionsConversationView_plus">+%1$d</string> + <!-- ReactionsRecipientAdapter --> + <string name="ReactionsRecipientAdapter_you">Vous</string> + <string name="reaction_notification">%1$s a réagi à un message %2$s</string> + <string name="ReactionsConversationView_show_less">Masquer les détails</string> + <string name="KeyboardPagerFragment_search_emoji">Rechercher un émoticône</string> + <string name="KeyboardPagerfragment_back_to_emoji">Retour à l\'émoticône</string> + <string name="KeyboardPagerfragment_clear_search_entry">Effacer la recherche</string> + <string name="activity_appearance_follow_system_category">Thème sombre automatique</string> + <string name="activity_appearance_follow_system_explanation">Faire correspondre aux paramètres systèmes</string> + <string name="go_to_device_notification_settings">Accédez aux paramètres de notifications de l\'appareil</string> + <string name="blocked_contacts_title">Contacts bloqués</string> + <string name="blocked_contacts_empty_state">Vous n\'avez aucun contact bloqué</string> + <string name="Unblock_dialog__title_single">Débloquer %s</string> + <string name="Unblock_dialog__title_multiple">Débloquer les utilisateurs</string> + <string name="Unblock_dialog__message">Êtes-vous sûr·e de vouloir débloquer %s ?</string> + <plurals name="Unblock_dialog__message_multiple_overflow"> + <item quantity="one">et %d autre</item> + <item quantity="other">et %d autres</item> + </plurals> + <plurals name="ReactionsRecipientAdapter_other_reactors"> + <item quantity="one">Et %1$d autre a réagi %2$s à ce message</item> + <item quantity="other">Et %1$d autres ont réagi %2$s à ce message</item> + </plurals> + <string name="dialog_new_conversation_title">Nouvelle conversation</string> + <string name="dialog_new_message_title">Nouveau message</string> + <string name="activity_create_group_title">Créer un groupe</string> + <string name="dialog_join_community_title">Rejoindre la communauté</string> + <string name="new_conversation_contacts_title">Contacts</string> + <string name="new_conversation_unknown_contacts_section_title">Inconnu·e</string> + <string name="fragment_enter_public_key_prompt">Commencez une nouvelle conversation en entrant l\'ID Session de quelqu\'un ou en lui partageant votre ID Session.</string> + <string name="activity_create_group_create_button_title">Créer</string> + <string name="search_contacts_hint">Rechercher parmi les contacts</string> + <string name="activity_join_public_chat_enter_community_url_tab_title">URL de la communauté</string> + <string name="fragment_enter_community_url_edit_text_hint">Entrez l\'URL de la communauté</string> + <string name="fragment_enter_community_url_join_button_title">Rejoindre</string> + <string name="new_conversation_dialog_back_button_content_description">Revenir en arrière</string> + <string name="new_conversation_dialog_close_button_content_description">Fermer la fenêtre</string> + <string name="ErrorNotifier_migration">Échec de la mise à jour de la base de données</string> + <string name="ErrorNotifier_migration_downgrade">Veuillez contacter le support pour signaler l\'erreur.</string> + <string name="delivery_status_sending">Envoi</string> + <string name="delivery_status_read">Lu</string> + <string name="delivery_status_sent">Envoyé</string> + <string name="delivery_status_failed">Échec d’envoi</string> </resources> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9d58ab9178..3aeeba0550 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,10 +1,10 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="app_name">Session</string> <string name="yes">Oui</string> <string name="no">Non</string> <string name="delete">Supprimer</string> <string name="ban">Bannir</string> + <string name="please_wait">Veuillez patienter…</string> <string name="save">Enregistrer</string> <string name="note_to_self">Note à mon intention</string> <string name="version_s">Version %s</string> @@ -19,7 +19,7 @@ </plurals> <string name="ApplicationPreferencesActivity_delete_all_old_messages_now">Supprimer tous les anciens messages maintenant ?</string> <plurals name="ApplicationPreferencesActivity_this_will_immediately_trim_all_conversations_to_the_d_most_recent_messages"> - <item quantity="one">Cela va immédiatement réduire toutes les conversations pour qu’il ne reste que le message le plus récent.</item> + <item quantity="one">Cela réduira immédiatement toutes les conversations au message le plus récent.</item> <item quantity="other">Cela réduira immédiatement toutes les conversations aux %d messages les plus récents.</item> </plurals> <string name="ApplicationPreferencesActivity_delete">Supprimer</string> @@ -63,6 +63,7 @@ <string name="ConversationActivity_muted_until_date">Son désactivé jusqu\'à %1$s</string> <string name="ConversationActivity_muted_forever">En sourdine</string> <string name="ConversationActivity_member_count">%1$d membres</string> + <string name="ConversationActivity_active_member_count">%1$d membres actifs</string> <string name="ConversationActivity_open_group_guidelines">Règles de la communauté</string> <string name="ConversationActivity_invalid_recipient">Le destinataire est invalide !</string> <string name="ConversationActivity_added_to_home_screen">Ajouté à l’écran d’accueil</string> @@ -82,7 +83,9 @@ <string name="ConversationActivity_to_send_photos_and_video_allow_signal_access_to_storage">Session a besoin d\'un accès au stockage pour envoyer des photos et des vidéos.</string> <string name="ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video">Session a besoin de l’autorisation Appareil photo afin de prendre des photos ou des vidéos, mais elle a été refusée définitivement. Veuillez accéder au menu des paramètres des applis, sélectionner Autorisations et activer Appareil photo.</string> <string name="ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video">Session a besoin de l’autorisation Appareil photo pour prendre des photos ou des vidéos</string> - <string name="ConversationActivity_search_position">%1$d de %2$d</string> + <string name="ConversationActivity_search_position">%1$d sur %2$d</string> + <string name="ConversationActivity_call_title">Autorisations d\'appel requises</string> + <string name="ConversationActivity_call_prompt">Vous pouvez activer la permission \"Appels vocaux et vidéo\" dans les paramètres de confidentialité.</string> <!-- ConversationFragment --> <plurals name="ConversationFragment_delete_selected_messages"> <item quantity="one">Supprimer le message sélectionné ?</item> @@ -99,13 +102,17 @@ <item quantity="other">L’enregistrement des %1$d médias dans la mémoire permettra à n’importe quelles autres applis de votre appareil d’y accéder.\n\nContinuer ?</item> </plurals> <plurals name="ConversationFragment_error_while_saving_attachments_to_sd_card"> - <item quantity="one">Erreur d’enregistrement de la pièce jointe dans la mémoire !</item> + <item quantity="one">Erreur lors de l’enregistrement de la pièce jointe dans la mémoire !</item> <item quantity="other">Erreur d’enregistrement des pièces jointes dans la mémoire !</item> </plurals> <plurals name="ConversationFragment_saving_n_attachments"> <item quantity="one">Enregistrement de la pièce jointe</item> <item quantity="other">Enregistrement de %1$d pièces jointes</item> </plurals> + <plurals name="ConversationFragment_saving_n_attachments_to_sd_card"> + <item quantity="one">Enregistrement de la pièce jointe dans la mémoire…</item> + <item quantity="other">Enregistrement de %1$d pièces jointes dans la mémoire…</item> + </plurals> <!-- CreateProfileActivity --> <string name="CreateProfileActivity_profile_photo">Photo de profil</string> <!-- CustomDefaultPreference --> @@ -156,7 +163,7 @@ <!-- MediaPickerActivity --> <string name="MediaPickerActivity_send_to">Envoyer à %s</string> <!-- MediaSendActivity --> - <string name="MediaSendActivity_add_a_caption">Ajouter un légende…</string> + <string name="MediaSendActivity_add_a_caption">Ajouter une légende...</string> <string name="MediaSendActivity_an_item_was_removed_because_it_exceeded_the_size_limit">Un élément a été supprimé, car il dépassait la taille limite</string> <string name="MediaSendActivity_camera_unavailable">L’appareil photo n’est pas disponible</string> <string name="MediaSendActivity_message_to_s">Message à %s</string> @@ -191,6 +198,7 @@ <string name="Slide_video">Vidéo</string> <!-- SmsMessageRecord --> <string name="SmsMessageRecord_received_corrupted_key_exchange_message">Vous avez reçu un message d’échange de clés corrompu !</string> + <string name="SmsMessageRecord_received_key_exchange_message_for_invalid_protocol_version"> Le message d\'échange de clé reçu est pour une version du protocole invalide. </string> <string name="SmsMessageRecord_received_message_with_new_safety_number_tap_to_process">Vous avez reçu un message avec un nouveau numéro de sécurité. Touchez pour le traiter et l’afficher.</string> <string name="SmsMessageRecord_secure_session_reset">Vous avez réinitialisé la session sécurisée.</string> <string name="SmsMessageRecord_secure_session_reset_s">%s a réinitialisé la session sécurisée.</string> @@ -201,7 +209,7 @@ <string name="ThreadRecord_secure_session_reset">La session sécurisée a été réinitialisée.</string> <string name="ThreadRecord_draft">Brouillon :</string> <string name="ThreadRecord_called">Vous avez appelé</string> - <string name="ThreadRecord_called_you">Vous a appelé</string> + <string name="ThreadRecord_called_you">Vous a appelé·e</string> <string name="ThreadRecord_missed_call">Appel manqué</string> <string name="ThreadRecord_media_message">Message multimédia</string> <string name="ThreadRecord_s_is_on_signal">%s est sur Session !</string> @@ -304,7 +312,7 @@ <string name="conversation_activity__send">Envoyer</string> <string name="conversation_activity__compose_description">Rédaction d’un message</string> <string name="conversation_activity__emoji_toggle_description">Afficher, masquer le clavier des émojis</string> - <string name="conversation_activity__attachment_thumbnail">Imagette de pièces jointes</string> + <string name="conversation_activity__attachment_thumbnail">Vignette de pièce jointe</string> <string name="conversation_activity__quick_attachment_drawer_toggle_camera_description">Afficher, masquer le tiroir permettant de lancer l’appareil photo à basse résolution</string> <string name="conversation_activity__quick_attachment_drawer_record_and_send_audio_description">Enregistrer et envoyer une pièce jointe audio</string> <string name="conversation_activity__quick_attachment_drawer_lock_record_description">Verrouiller l’enregistrement de pièces jointes audio</string> @@ -378,9 +386,9 @@ <string name="arrays__settings_default">Valeur par défaut</string> <string name="arrays__enabled">Activé</string> <string name="arrays__disabled">Désactivé</string> - <string name="arrays__name_and_message">Nom et message</string> - <string name="arrays__name_only">Nom seulement</string> - <string name="arrays__no_name_or_message">Aucun nom ni message</string> + <string name="arrays__name_and_message">Nom et Contenu</string> + <string name="arrays__name_only">Nom uniquement</string> + <string name="arrays__no_name_or_message">Aucun nom ni contenu</string> <string name="arrays__images">Images</string> <string name="arrays__audio">Son</string> <string name="arrays__video">Vidéo</string> @@ -398,9 +406,14 @@ <item quantity="other">%d heures</item> </plurals> <!-- preferences.xml --> - <string name="preferences__pref_enter_sends_title">La touche Entrée envoie</string> - <string name="preferences__send_link_previews">Envoyer des aperçus de liens</string> - <string name="preferences__previews_are_supported_for">Les aperçus sont pris en charge pour les liens Imgur, Instagram, Pinterest, Reddit et YouTube</string> + <string name="preferences__pref_enter_sends_title">Envoyer avec bouton Entrée</string> + <string name="preferences__pref_enter_sends_summary">Appuyer sur la touche Entrée enverra un message au lieu de commencer une nouvelle ligne.</string> + <string name="preferences__send_link_previews">Envoyer les aperçus des liens</string> + <string name="preferences__link_previews">Aperçus des liens</string> + <string name="preferences__link_previews_summary">Générer les aperçus des liens pour les URLs supportés.</string> + <string name="preferences__pref_autoplay_audio_category">Messages audio</string> + <string name="preferences__pref_autoplay_audio_title">Lire automatiquement les messages audios</string> + <string name="preferences__pref_autoplay_audio_summary">Lire automatiquement les messages audio consécutifs.</string> <string name="preferences__screen_security">Sécurité de l’écran</string> <string name="preferences__disable_screen_security_to_allow_screen_shots">Bloquer les captures d’écran dans la liste des récents et dans l’appli</string> <string name="preferences__notifications">Notifications</string> @@ -408,6 +421,7 @@ <string name="preferences__led_color_unknown">Inconnue</string> <string name="preferences__pref_led_blink_title">Rythme de clignotement de la DEL</string> <string name="preferences__sound">Son</string> + <string name="preferences__in_app_sounds">Son à l\'ouverture de l\'application</string> <string name="preferences__silent">Silencieux</string> <string name="preferences__repeat_alerts">Répéter les alertes</string> <string name="preferences__never">Jamais</string> @@ -436,18 +450,27 @@ <string name="preferences__default">Valeur par défaut</string> <string name="preferences__incognito_keyboard">Clavier incognito</string> <string name="preferences__read_receipts">Accusés de lecture</string> + <string name="preferences__read_receipts_summary">Envoyer des accusés de lecture dans les conversations individuelles.</string> <string name="preferences__typing_indicators">Indicateurs de saisie</string> - <string name="preferences__if_typing_indicators_are_disabled_you_wont_be_able_to_see_typing_indicators">Si les indicateurs de saisie sont désactivés, vous ne serez pas en mesure de voir les indicateurs de saisie des autres.</string> + <string name="preferences__typing_indicators_summary">Voir et envoyer les indicateurs de saisie dans les conversations un à un.</string> <string name="preferences__request_keyboard_to_disable_personalized_learning">Demander au clavier de désactiver l’apprentissage personnalisé</string> <string name="preferences__light_theme">Clair</string> <string name="preferences__dark_theme">Sombre</string> <string name="preferences_chats__message_trimming">Élagage des messages</string> + <string name="preferences_chats__message_trimming_title">Raccourcir les communautés</string> + <string name="preferences_chats__message_trimming_summary">Supprimer des messages de plus de 6 mois dans des communautés qui ont plus de 2 000 messages.</string> <string name="preferences_advanced__use_system_emoji">Utiliser les émojis du système</string> <string name="preferences_advanced__disable_signal_built_in_emoji_support">Désactiver la prise en charge des émojis intégrés à Session</string> + <string name="preferences_app_protection__screen_security">Sécurité d\'écran</string> <string name="preferences_chats__chats">Conversations</string> <string name="preferences_notifications__messages">Messages</string> <string name="preferences_notifications__in_chat_sounds">Sons des conversations</string> + <string name="preferences_notifications__content">Contenu de la notification</string> + <string name="preferences_notifications__content_message">Afficher :</string> + <string name="preferences_notifications__summary">Informations affichées dans les notifications.</string> <string name="preferences_notifications__priority">Priorité</string> + <string name="preferences_app_protection__screenshot_notifications">Notifications de capture d\'écran</string> + <string name="preferences_app_protected__screenshot_notifications_summary">Recevoir une notification lorsqu\'un contact prend une capture d\'écran d\'une conversation individuelle.</string> <!-- **************************************** --> <!-- menus --> <!-- **************************************** --> @@ -460,7 +483,10 @@ <string name="conversation_context__menu_ban_user">Bannir l\'utilisateur</string> <string name="conversation_context__menu_ban_and_delete_all">Bannir et supprimer tout</string> <string name="conversation_context__menu_resend_message">Renvoyer le message</string> + <string name="conversation_context__menu_reply">Répondre</string> <string name="conversation_context__menu_reply_to_message">Répondre au message</string> + <string name="conversation_context__menu_call">Appeler</string> + <string name="conversation_context__menu_select">Sélectionner</string> <!-- conversation_context_image --> <string name="conversation_context_image__save_attachment">Enregistrer la pièce jointe</string> <!-- conversation_expiring_off --> @@ -513,8 +539,8 @@ <string name="LocalBackupJob_creating_backup">Création de la sauvegarde…</string> <string name="ProgressPreference_d_messages_so_far">%d messages pour l’instant</string> <string name="BackupUtil_never">Jamais</string> - <string name="preferences_app_protection__screen_lock">Verrouillage de l’écran</string> - <string name="preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint">Verrouiller l’accès à Session avec le verrouillage de l’écran d’Android ou une empreinte</string> + <string name="preferences_app_protection__screen_lock">Verrouiller Session</string> + <string name="preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint">Nécessite une empreinte digitale, un code PIN, un schéma ou un mot de passe pour déverrouiller Session.</string> <string name="preferences_app_protection__screen_lock_inactivity_timeout">Délai d’inactivité avant verrouillage de l’écran</string> <string name="AppProtectionPreferenceFragment_none">Aucune</string> <!-- Conversation activity --> @@ -522,6 +548,7 @@ <!-- Session --> <string name="continue_2">Continuer</string> <string name="copy">Copier</string> + <string name="close">Fermer</string> <string name="invalid_url">URL non valide</string> <string name="copied_to_clipboard">Copié dans le presse-papier</string> <string name="next">Suivant</string> @@ -573,12 +600,12 @@ <string name="activity_path_resolving_progress">Contact en cours…</string> <string name="activity_create_private_chat_title">Nouvelle Session</string> <string name="activity_create_private_chat_enter_session_id_tab_title">Saisir un Session ID</string> - <string name="activity_create_private_chat_scan_qr_code_tab_title">Scanner un Code QR</string> - <string name="activity_create_private_chat_scan_qr_code_explanation">Scannez le code QR d\'un utilisateur pour démarrer une session. Les codes QR peuvent se trouver en touchant l\'icône du code QR dans les paramètres du compte.</string> + <string name="activity_create_private_chat_scan_qr_code_tab_title">Scanner un QR Code</string> + <string name="activity_create_private_chat_scan_qr_code_explanation">Scannez le QR code d\'un utilisateur pour démarrer une session. Les QR codes peuvent se trouver en touchant l\'icône du QR code dans les paramètres du compte.</string> <string name="fragment_enter_public_key_edit_text_hint">Entrer un Session ID ou un nom ONS</string> <string name="fragment_enter_public_key_explanation">Les utilisateurs peuvent partager leur Session ID depuis les paramètres du compte ou en utilisant le code QR.</string> <string name="fragment_enter_public_key_error_message">Veuillez vérifier le Session ID ou le nom ONS et réessayer.</string> - <string name="fragment_scan_qr_code_camera_access_explanation">Session a besoin d\'accéder à l\'appareil photo pour scanner les codes QR</string> + <string name="fragment_scan_qr_code_camera_access_explanation">Session a besoin d\'accéder à l\'appareil photo pour scanner les QR codes</string> <string name="fragment_scan_qr_code_grant_camera_access_button_title">Autoriser l\'accès</string> <string name="activity_create_closed_group_title">Nouveau groupe privé</string> <string name="activity_create_closed_group_edit_text_hint">Saisissez un nom de groupe</string> @@ -591,7 +618,7 @@ <string name="activity_join_public_chat_title">Joindre un groupe public</string> <string name="activity_join_public_chat_error">Impossible de rejoindre le groupe</string> <string name="activity_join_public_chat_enter_group_url_tab_title">URL du groupe public</string> - <string name="activity_join_public_chat_scan_qr_code_tab_title">Scannez le code QR</string> + <string name="activity_join_public_chat_scan_qr_code_tab_title">Scanner le QR Code</string> <string name="activity_join_public_chat_scan_qr_code_explanation">Scannez le code QR du groupe public que vous souhaitez rejoindre</string> <string name="fragment_enter_chat_url_edit_text_hint">Saisissez une URL de groupe public</string> <string name="activity_settings_title">Paramètres</string> @@ -600,6 +627,7 @@ <string name="activity_settings_display_name_too_long_error">Veuillez choisir un nom d\'utilisateur plus court</string> <string name="activity_settings_privacy_button_title">Confidentialité</string> <string name="activity_settings_notifications_button_title">Notifications</string> + <string name="activity_settings_message_requests_button_title">Demandes de message</string> <string name="activity_settings_chats_button_title">Conversations</string> <string name="activity_settings_devices_button_title">Appareils reliés</string> <string name="activity_settings_invite_button_title">Inviter un ami</string> @@ -612,25 +640,39 @@ <string name="activity_notification_settings_style_section_title">Style de notification</string> <string name="activity_notification_settings_content_section_title">Contenu de notification</string> <string name="activity_privacy_settings_title">Confidentialité</string> + <string name="activity_conversations_settings_title">Conversations</string> + <string name="activity_help_settings_title">Aide</string> + <string name="activity_help_settings__report_bug_title">Signaler un bug</string> + <string name="activity_help_settings__report_bug_summary">Exportez vos logs, puis télécharger le fichier au service d\'aide de Session.</string> + <string name="activity_help_settings__translate_session">Traduire Session</string> + <string name="activity_help_settings__feedback">Nous aimerions avoir votre avis</string> + <string name="activity_help_settings__faq">FAQ</string> + <string name="activity_help_settings__support">Assistance</string> + <string name="activity_help_settings__export_logs">Exporter les journaux</string> <string name="preferences_notifications_strategy_category_title">Stratégie de notification</string> <string name="preferences_notifications_strategy_category_fast_mode_title">Utiliser le Mode Rapide</string> <string name="preferences_notifications_strategy_category_fast_mode_summary">Vous serez averti de nouveaux messages de manière fiable et immédiate en utilisant les serveurs de notification de Google.</string> <string name="fragment_device_list_bottom_sheet_change_name_button_title">Modifier le nom</string> <string name="fragment_device_list_bottom_sheet_unlink_device_button_title">Déconnecter l\'appareil</string> <string name="dialog_seed_title">Votre phrase de récupération</string> - <string name="dialog_seed_explanation">Ceci est votre phrase de récupération. Elle vous permet de restaurer ou migrer votre Session ID vers un nouvel appareil.</string> + <string name="dialog_seed_explanation">Vous pouvez utiliser votre phrase de récupération pour restaurer votre compte ou relier un appareil.</string> <string name="dialog_clear_all_data_title">Effacer toutes les données</string> <string name="dialog_clear_all_data_explanation">Cela supprimera définitivement vos messages, vos sessions et vos contacts.</string> <string name="dialog_clear_all_data_network_explanation">Souhaitez-vous effacer seulement cet appareil ou supprimer l\'ensemble de votre compte ?</string> + <string name="dialog_clear_all_data_message">Cela supprimera définitivement vos messages, sessions et contacts. Voulez-vous uniquement effacer cet appareil ou supprimer l\'intégralité de votre compte ?</string> + <string name="dialog_clear_all_data_clear_device_only">Effacer l\'appareil uniquement</string> + <string name="dialog_clear_all_data_clear_device_and_network">Effacer l\'appareil et le réseau</string> + <string name="dialog_clear_all_data_clear_device_and_network_confirmation">Êtes-vous sûr de vouloir supprimer vos données du réseau ? Si vous continuez, vous ne pourrez pas restaurer vos messages ou vos contacts.</string> + <string name="dialog_clear_all_data_clear">Effacer</string> <string name="dialog_clear_all_data_local_only">Effacer seulement</string> <string name="dialog_clear_all_data_clear_network">Compte complet</string> - <string name="activity_qr_code_title">Code QR</string> - <string name="activity_qr_code_view_my_qr_code_tab_title">Afficher mon code QR</string> - <string name="activity_qr_code_view_scan_qr_code_tab_title">Scanner le code QR</string> - <string name="activity_qr_code_view_scan_qr_code_explanation">Scannez le code QR d\'un autre utilisateur pour démarrer une session</string> + <string name="activity_qr_code_title">QR Code</string> + <string name="activity_qr_code_view_my_qr_code_tab_title">Afficher mon QR code</string> + <string name="activity_qr_code_view_scan_qr_code_tab_title">Scanner le QR Code</string> + <string name="activity_qr_code_view_scan_qr_code_explanation">Scannez le QR code d\'un autre utilisateur pour démarrer une session</string> <string name="fragment_view_my_qr_code_title">Scannez-moi</string> - <string name="fragment_view_my_qr_code_explanation">Ceci est votre code QR. Les autres utilisateurs peuvent le scanner pour démarrer une session avec vous.</string> - <string name="fragment_view_my_qr_code_share_title">Partager le code QR</string> + <string name="fragment_view_my_qr_code_explanation">Ceci est votre QR code. Les autres utilisateurs peuvent le scanner pour démarrer une session avec vous.</string> + <string name="fragment_view_my_qr_code_share_title">Partager le QR code</string> <string name="fragment_contact_selection_contacts_title">Contacts</string> <string name="fragment_contact_selection_closed_groups_title">Groupes privés</string> <string name="fragment_contact_selection_open_groups_title">Groupes publics</string> @@ -664,8 +706,8 @@ <string name="activity_link_device_skip_prompt">Cela prend un certain temps, voulez-vous passer ?</string> <string name="activity_link_device_link_device">Relier un appareil</string> <string name="activity_link_device_recovery_phrase">Phrase de récupération</string> - <string name="activity_link_device_scan_qr_code">Scannez le code QR</string> - <string name="activity_link_device_qr_message">Allez dans Paramètres → Phrase de récupération sur votre autre appareil pour afficher votre code QR.</string> + <string name="activity_link_device_scan_qr_code">Scanner le QR Code</string> + <string name="activity_link_device_qr_message">Allez dans Paramètres → Phrase de récupération sur votre autre appareil pour afficher votre QR code.</string> <string name="activity_join_public_chat_join_rooms">Ou rejoignez l\'un(e) de ceux-ci…</string> <string name="activity_pn_mode_message_notifications">Notifications de message</string> <string name="activity_pn_mode_explanation">Session peut vous avertir de la présence de nouveaux messages de deux façons.</string> @@ -694,6 +736,7 @@ <string name="dialog_download_explanation">Êtes-vous sûr de vouloir télécharger le média envoyé par %s ?</string> <string name="dialog_download_button_title">Télécharger</string> <string name="activity_conversation_blocked_banner_text">%s est bloqué. Débloquer ?</string> + <string name="activity_conversation_block_user">Bloquer l\'utilisateur</string> <string name="activity_conversation_attachment_prep_failed">La préparation de la pièce jointe pour l\'envoi a échoué.</string> <string name="media">Médias</string> <string name="UntrustedAttachmentView_download_attachment">Touchez pour télécharger %s</string> @@ -711,6 +754,116 @@ <string name="activity_settings_support">Journal de débogage</string> <string name="dialog_share_logs_title">Partager les logs</string> <string name="dialog_share_logs_explanation">Voulez-vous exporter les logs de votre application pour pouvoir partager pour le dépannage ?</string> - <string name="conversation_pin">Code pin</string> + <string name="conversation_pin">Épingler</string> <string name="conversation_unpin">Désépingler</string> + <string name="mark_all_as_read">Tout marquer comme lu</string> + <string name="global_search_contacts_groups">Contacts et Groupes</string> + <string name="global_search_messages">Messages</string> + <string name="activity_message_requests_title">Demandes de message</string> + <string name="message_requests_send_notice">Envoyer un message à cet utilisateur acceptera automatiquement sa demande de message et révélera votre ID de session.</string> + <string name="accept">Accepter</string> + <string name="decline">Refuser</string> + <string name="message_requests_clear_all">Effacer tout</string> + <string name="message_requests_decline_message">Êtes-vous sûr de vouloir refuser cette demande de message ?</string> + <string name="message_requests_block_message">Êtes-vous sûr de vouloir supprimer cette demande de message ?</string> + <string name="message_requests_deleted">Demande de message supprimée</string> + <string name="message_requests_clear_all_message">Êtes-vous sûr de vouloir supprimer toutes les demandes de message ?</string> + <string name="message_requests_cleared">Demandes de message supprimées</string> + <string name="message_requests_accepted">Votre demande de message a été acceptée.</string> + <string name="message_requests_pending">Votre demande de message est en attente.</string> + <string name="message_request_empty_state_message">Aucune demande de message en attente</string> + <string name="NewConversationButton_SessionTooltip">Message privé</string> + <string name="NewConversationButton_ClosedGroupTooltip">Groupes privés</string> + <string name="NewConversationButton_OpenGroupTooltip">Groupe public</string> + <string name="message_requests_notification">Vous avez une nouvelle demande de message</string> + <string name="CallNotificationBuilder_connecting">Connexion…</string> + <string name="NotificationBarManager__incoming_signal_call">Appel entrant</string> + <string name="NotificationBarManager__deny_call">Refuser l’appel</string> + <string name="NotificationBarManager__answer_call">Répondre à l’appel</string> + <string name="NotificationBarManager_call_in_progress">Appel en cours</string> + <string name="NotificationBarManager__cancel_call">Annuler l’appel</string> + <string name="NotificationBarManager__establishing_signal_call">Établissement de l\'appel</string> + <string name="NotificationBarManager__end_call">Raccrocher</string> + <string name="accept_call">Accepter l\'appel</string> + <string name="decline_call">Refuser l\'appel</string> + <string name="preferences__voice_video_calls">Appels vocaux et vidéos</string> + <string name="preferences__calls_beta">Appels (Bêta)</string> + <string name="preferences__allow_access_voice_video">Active les appels vocaux et vidéo vers et depuis d\'autres utilisateurs.</string> + <string name="dialog_voice_video_title">Appels vocaux / vidéo</string> + <string name="dialog_voice_video_message">La version actuelle des appels vocaux/vidéo exposera votre adresse IP aux serveurs de la Fondation Oxen et aux utilisateurs appelés</string> + <string name="CallNotificationBuilder_first_call_title">Appel Manqué</string> + <string name="CallNotificationBuilder_first_call_message">Vous avez manqué un appel car vous devez activer la permission « Appels vocaux et vidéo » dans les paramètres de confidentialité.</string> + <string name="WebRtcCallActivity_Session_Call">Appel Session</string> + <string name="WebRtcCallActivity_Reconnecting">Reconnexion…</string> + <string name="CallNotificationBuilder_system_notification_title">Notifications</string> + <string name="CallNotificationBuilder_system_notification_message">Les notifications désactivées vous empêcheront de recevoir des appels, aller dans les paramètres de notification de session?</string> + <string name="dismiss">Rejeter</string> + <string name="activity_settings_conversations_button_title">Conversations</string> + <string name="activity_settings_message_appearance_button_title">Apparence</string> + <string name="activity_settings_help_button">Aide</string> + <string name="activity_appearance_themes_category">Thèmes</string> + <string name="ocean_dark_theme_name">Océan sombre</string> + <string name="classic_dark_theme_name">Sombre classique</string> + <string name="ocean_light_theme_name">Océan lumineux</string> + <string name="classic_light_theme_name">Clair classique</string> + <string name="activity_appearance_primary_color_category">Couleur principale</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__this_message">Ce message</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__recently_used">Fréquemment Utilisés</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__smileys_and_people">Émoticônes et personnes</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__nature" comment="Heading for an emoji list's category">Nature</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__food" comment="Heading for an emoji list's category">Nourriture</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__activities">Activités</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__places" comment="Heading for an emoji list's category">Voyage</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__objects">Objets</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__symbols">Symboles</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__flags">Drapeaux</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__emoticons">Emoticônes</string> + <string name="ReactWithAnyEmojiBottomSheetDialogFragment__no_results_found">Aucun résultat trouvé</string> + <!-- ReactionsBottomSheetDialogFragment --> + <string name="ReactionsBottomSheetDialogFragment_all">Tous · %1$d</string> + <!-- ReactionsConversationView --> + <string name="ReactionsConversationView_plus">+%1$d</string> + <!-- ReactionsRecipientAdapter --> + <string name="ReactionsRecipientAdapter_you">Vous</string> + <string name="reaction_notification">%1$s a réagi à un message %2$s</string> + <string name="ReactionsConversationView_show_less">Masquer les détails</string> + <string name="KeyboardPagerFragment_search_emoji">Rechercher un émoticône</string> + <string name="KeyboardPagerfragment_back_to_emoji">Retour à l\'émoticône</string> + <string name="KeyboardPagerfragment_clear_search_entry">Effacer la recherche</string> + <string name="activity_appearance_follow_system_category">Thème sombre automatique</string> + <string name="activity_appearance_follow_system_explanation">Faire correspondre aux paramètres systèmes</string> + <string name="go_to_device_notification_settings">Accédez aux paramètres de notifications de l\'appareil</string> + <string name="blocked_contacts_title">Contacts bloqués</string> + <string name="blocked_contacts_empty_state">Vous n\'avez aucun contact bloqué</string> + <string name="Unblock_dialog__title_single">Débloquer %s</string> + <string name="Unblock_dialog__title_multiple">Débloquer les utilisateurs</string> + <string name="Unblock_dialog__message">Êtes-vous sûr·e de vouloir débloquer %s ?</string> + <plurals name="Unblock_dialog__message_multiple_overflow"> + <item quantity="one">et %d autre</item> + <item quantity="other">et %d autres</item> + </plurals> + <plurals name="ReactionsRecipientAdapter_other_reactors"> + <item quantity="one">Et %1$d autre a réagi %2$s à ce message</item> + <item quantity="other">Et %1$d autres ont réagi %2$s à ce message</item> + </plurals> + <string name="dialog_new_conversation_title">Nouvelle conversation</string> + <string name="dialog_new_message_title">Nouveau message</string> + <string name="activity_create_group_title">Créer un groupe</string> + <string name="dialog_join_community_title">Rejoindre la communauté</string> + <string name="new_conversation_contacts_title">Contacts</string> + <string name="new_conversation_unknown_contacts_section_title">Inconnu·e</string> + <string name="fragment_enter_public_key_prompt">Commencez une nouvelle conversation en entrant l\'ID Session de quelqu\'un ou en lui partageant votre ID Session.</string> + <string name="activity_create_group_create_button_title">Créer</string> + <string name="search_contacts_hint">Rechercher parmi les contacts</string> + <string name="activity_join_public_chat_enter_community_url_tab_title">URL de la communauté</string> + <string name="fragment_enter_community_url_edit_text_hint">Entrez l\'URL de la communauté</string> + <string name="fragment_enter_community_url_join_button_title">Rejoindre</string> + <string name="new_conversation_dialog_back_button_content_description">Revenir en arrière</string> + <string name="new_conversation_dialog_close_button_content_description">Fermer la fenêtre</string> + <string name="ErrorNotifier_migration">Échec de la mise à jour de la base de données</string> + <string name="ErrorNotifier_migration_downgrade">Veuillez contacter le support pour signaler l\'erreur.</string> + <string name="delivery_status_sending">Envoi</string> + <string name="delivery_status_read">Lu</string> + <string name="delivery_status_sent">Envoyé</string> + <string name="delivery_status_failed">Échec d’envoi</string> </resources> diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index 3c8b4a54c8..517efa5604 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -92,7 +92,8 @@ <item quantity="one">Ez végelegesen törölni fogja a kiválasztott üzenetet.</item> <item quantity="other">Ez véglegesen törölni fogja mind a(z) %1$d db kiválasztott üzenetet.</item> </plurals> - <string name="ConversationFragment_ban_selected_user">Tiltja ezt a felhasználót?</string> + <string name="ConversationActivity_call_title">Hívás engedély szükséges</string> + <string name="ConversationFragment_ban_selected_user">Tiltod ezt a felhasználót?</string> <string name="ConversationFragment_save_to_sd_card">Mentés tárolóra?</string> <plurals name="ConversationFragment_saving_n_media_to_storage_warning"> <item quantity="one">A média mentése a tárolóra lehetővé teszi bármelyik másik alkalmazásnak a készülékeden, hogy hozzáférjen.\n\nFolytatod?</item> @@ -206,7 +207,7 @@ <string name="ThreadRecord_called_you">Hívott téged</string> <string name="ThreadRecord_missed_call">Nem fogadott hívás</string> <string name="ThreadRecord_media_message">Média üzenet</string> - <string name="ThreadRecord_s_is_on_signal">%s a Session-on van!</string> + <string name="ThreadRecord_s_is_on_signal">%s elérhető a Session-on!</string> <string name="ThreadRecord_disappearing_messages_disabled">Eltűnő üzenetek letiltva</string> <string name="ThreadRecord_disappearing_message_time_updated_to_s">Eltűnő üzenet ideje beállítva erre: %s</string> <string name="ThreadRecord_s_took_a_screenshot">%s készített egy képernyőképet.</string> @@ -236,7 +237,7 @@ <string name="MediaPreviewActivity_you">Te</string> <string name="MediaPreviewActivity_unssuported_media_type">Nem támogatott médiatípus</string> <string name="MediaPreviewActivity_draft">Piszkozat</string> - <string name="MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied">A Session- nek szüksége van fájlhozzáférési engedélyekhez, hogy menteni tudjon külső tárhelyre, de ezt az engedélyt megtagadták. Kérjük, hogy a készüléke beállításaiban engedélyezze a \"fájlok és média\" opciót a Session számára.</string> + <string name="MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied">A Session- nek szüksége van fájlhozzáférési engedélyekhez, hogy menteni tudjon külső tárhelyre. Kérlek, hogy a rendszerbeállításokban engedélyezd a \"fájlok és média\" opciót a Session számára.</string> <string name="MediaPreviewActivity_unable_to_write_to_external_storage_without_permission">Nem lehet engedély nélkül menteni a külső tárolóra</string> <string name="MediaPreviewActivity_media_delete_confirmation_title">Törlöd az üzenetet?</string> <string name="MediaPreviewActivity_media_delete_confirmation_message">Ez véglegesen törölni fogja ezt az üzenetet.</string> @@ -250,8 +251,8 @@ <string name="MessageNotifier_mark_all_as_read">Összes megjelölése olvasottként</string> <string name="MessageNotifier_mark_read">Olvasottnak jelöl</string> <string name="MessageNotifier_reply">Válasz</string> - <string name="MessageNotifier_pending_signal_messages">Függő Session üzenetek</string> - <string name="MessageNotifier_you_have_pending_signal_messages">Függő Session üzeneteid vannak, koppints a megnyitáshoz és letöltéshez</string> + <string name="MessageNotifier_pending_signal_messages">Függőben lévő Session üzenetek</string> + <string name="MessageNotifier_you_have_pending_signal_messages">Függőben lévő Session üzeneteid vannak, koppints a megnyitáshoz és letöltéshez</string> <string name="MessageNotifier_contact_message">%1$s %2$s</string> <string name="MessageNotifier_unknown_contact_message">Névjegy</string> <!-- Notification Channels --> @@ -275,7 +276,7 @@ <!-- ShortcutLauncherActivity --> <string name="ShortcutLauncherActivity_invalid_shortcut">Érvénytelen parancsikon</string> <!-- SingleRecipientNotificationBuilder --> - <string name="SingleRecipientNotificationBuilder_signal">Ülés</string> + <string name="SingleRecipientNotificationBuilder_signal">Session</string> <string name="SingleRecipientNotificationBuilder_new_message">Új üzenet</string> <!-- TransferControlView --> <plurals name="TransferControlView_n_items"> @@ -530,17 +531,17 @@ <string name="share">Megosztás</string> <string name="invalid_session_id">Érvénytelen Session azonosító</string> <string name="cancel">Mégse</string> - <string name="your_session_id">Az ön Session azonosítója</string> - <string name="activity_landing_title_2">Az ön Session-ja itt kezdődik...</string> + <string name="your_session_id">A session azonosítód</string> + <string name="activity_landing_title_2">A Session itt kezdődik...</string> <string name="activity_landing_register_button_title">Session azonosító létrehozása</string> - <string name="activity_landing_restore_button_title">Folytassa az ülését</string> + <string name="activity_landing_restore_button_title">Session azonosító helyreállítása</string> <string name="view_fake_chat_bubble_1">Mi az a Session?</string> <string name="view_fake_chat_bubble_2">Ez egy decentralizált, titkosított üzenetküldő alkalmazás</string> <string name="view_fake_chat_bubble_3">Tehát nem gyűjti a személyes adataimat vagy a beszélgetés metaadatait? Hogyan működik?</string> - <string name="view_fake_chat_bubble_4">Fejlett névtelen útválasztási és end-to-end titkosítási technológiák kombinációjának használata.</string> - <string name="view_fake_chat_bubble_5">A barátok nem engedik, hogy a barátok kompromittált hírnököket használjanak. Szívesen.</string> + <string name="view_fake_chat_bubble_4">Fejlett anonim útválasztási és végponttól-végpontig titkosítási technológiák használatával.</string> + <string name="view_fake_chat_bubble_5">A barátként nem engedhetem, hogy megbízhatatlan üzenetküldő appokat használj. Szívesen.</string> <string name="activity_register_title">Ismerd meg a Session ID-d</string> - <string name="activity_register_explanation">Az üles azonosító az az egyedi cím, amelyet az emberek használhatnak, hogy kapcsolatba lépjenek Önnel az Ülés során. Mivel nincs kapcsolat a valódi személyazonosságával, az Ülés azonosító teljesen névtelen, és privát.</string> + <string name="activity_register_explanation">A Session azonosító az az egyedi cím, amelyet az emberek használhatnak, hogy kapcsolatba lépjenek Önnel a Sessionon. Mivel nincs kapcsolat a valódi személyazonosságával, az Session azonosító teljesen névtelen, és privát.</string> <string name="activity_restore_title">Fiók visszaállítása</string> <string name="activity_restore_explanation">Írja be azt a helyreállítási kifejezést, amelyet a fiók visszaállításához regisztrálásakor kapott.</string> <string name="activity_restore_seed_edit_text_hint">Írja be a helyreállítási kifejezést</string> @@ -552,7 +553,7 @@ <string name="activity_pn_mode_recommended_option_tag">Ajánlott</string> <string name="activity_pn_mode_no_option_picked_dialog_title">Kérjük, válasszon egy lehetőséget</string> <string name="activity_home_empty_state_message">Még nincsenek névjegyei</string> - <string name="activity_home_empty_state_button_title">Indítson el egy ülést</string> + <string name="activity_home_empty_state_button_title">Indítson el egy beszélgetést</string> <string name="activity_home_leave_group_dialog_message">Biztosan elhagyja ezt a csoportot?</string> <string name="activity_home_leaving_group_failed_message">"Nem sikerült kilépni a csoportból"</string> <string name="activity_home_delete_conversation_dialog_message">Biztosan törli ezt a beszélgetést?</string> @@ -573,8 +574,8 @@ <string name="activity_path_destination_row_title">Célállomás</string> <string name="activity_path_learn_more_button_title">Tudj meg többet</string> <string name="activity_path_resolving_progress">Feloldás...</string> - <string name="activity_create_private_chat_title">Új Ülés</string> - <string name="activity_create_private_chat_enter_session_id_tab_title">Adja meg az Ülés azonosítóját</string> + <string name="activity_create_private_chat_title">Új Session</string> + <string name="activity_create_private_chat_enter_session_id_tab_title">Adja meg a Session azonosítóját</string> <string name="activity_create_private_chat_scan_qr_code_tab_title">QR kód beolvasása</string> <string name="activity_create_private_chat_scan_qr_code_explanation">A beszélgetés elindításához olvassa be a felhasználó QR kódját. A QR kód a fiókbeállításokban található a QR kód ikonra koppintva.</string> <string name="fragment_enter_public_key_edit_text_hint">Írja be Session azonosítóját vagy ONS nevét</string> diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 3c8b4a54c8..517efa5604 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -92,7 +92,8 @@ <item quantity="one">Ez végelegesen törölni fogja a kiválasztott üzenetet.</item> <item quantity="other">Ez véglegesen törölni fogja mind a(z) %1$d db kiválasztott üzenetet.</item> </plurals> - <string name="ConversationFragment_ban_selected_user">Tiltja ezt a felhasználót?</string> + <string name="ConversationActivity_call_title">Hívás engedély szükséges</string> + <string name="ConversationFragment_ban_selected_user">Tiltod ezt a felhasználót?</string> <string name="ConversationFragment_save_to_sd_card">Mentés tárolóra?</string> <plurals name="ConversationFragment_saving_n_media_to_storage_warning"> <item quantity="one">A média mentése a tárolóra lehetővé teszi bármelyik másik alkalmazásnak a készülékeden, hogy hozzáférjen.\n\nFolytatod?</item> @@ -206,7 +207,7 @@ <string name="ThreadRecord_called_you">Hívott téged</string> <string name="ThreadRecord_missed_call">Nem fogadott hívás</string> <string name="ThreadRecord_media_message">Média üzenet</string> - <string name="ThreadRecord_s_is_on_signal">%s a Session-on van!</string> + <string name="ThreadRecord_s_is_on_signal">%s elérhető a Session-on!</string> <string name="ThreadRecord_disappearing_messages_disabled">Eltűnő üzenetek letiltva</string> <string name="ThreadRecord_disappearing_message_time_updated_to_s">Eltűnő üzenet ideje beállítva erre: %s</string> <string name="ThreadRecord_s_took_a_screenshot">%s készített egy képernyőképet.</string> @@ -236,7 +237,7 @@ <string name="MediaPreviewActivity_you">Te</string> <string name="MediaPreviewActivity_unssuported_media_type">Nem támogatott médiatípus</string> <string name="MediaPreviewActivity_draft">Piszkozat</string> - <string name="MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied">A Session- nek szüksége van fájlhozzáférési engedélyekhez, hogy menteni tudjon külső tárhelyre, de ezt az engedélyt megtagadták. Kérjük, hogy a készüléke beállításaiban engedélyezze a \"fájlok és média\" opciót a Session számára.</string> + <string name="MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied">A Session- nek szüksége van fájlhozzáférési engedélyekhez, hogy menteni tudjon külső tárhelyre. Kérlek, hogy a rendszerbeállításokban engedélyezd a \"fájlok és média\" opciót a Session számára.</string> <string name="MediaPreviewActivity_unable_to_write_to_external_storage_without_permission">Nem lehet engedély nélkül menteni a külső tárolóra</string> <string name="MediaPreviewActivity_media_delete_confirmation_title">Törlöd az üzenetet?</string> <string name="MediaPreviewActivity_media_delete_confirmation_message">Ez véglegesen törölni fogja ezt az üzenetet.</string> @@ -250,8 +251,8 @@ <string name="MessageNotifier_mark_all_as_read">Összes megjelölése olvasottként</string> <string name="MessageNotifier_mark_read">Olvasottnak jelöl</string> <string name="MessageNotifier_reply">Válasz</string> - <string name="MessageNotifier_pending_signal_messages">Függő Session üzenetek</string> - <string name="MessageNotifier_you_have_pending_signal_messages">Függő Session üzeneteid vannak, koppints a megnyitáshoz és letöltéshez</string> + <string name="MessageNotifier_pending_signal_messages">Függőben lévő Session üzenetek</string> + <string name="MessageNotifier_you_have_pending_signal_messages">Függőben lévő Session üzeneteid vannak, koppints a megnyitáshoz és letöltéshez</string> <string name="MessageNotifier_contact_message">%1$s %2$s</string> <string name="MessageNotifier_unknown_contact_message">Névjegy</string> <!-- Notification Channels --> @@ -275,7 +276,7 @@ <!-- ShortcutLauncherActivity --> <string name="ShortcutLauncherActivity_invalid_shortcut">Érvénytelen parancsikon</string> <!-- SingleRecipientNotificationBuilder --> - <string name="SingleRecipientNotificationBuilder_signal">Ülés</string> + <string name="SingleRecipientNotificationBuilder_signal">Session</string> <string name="SingleRecipientNotificationBuilder_new_message">Új üzenet</string> <!-- TransferControlView --> <plurals name="TransferControlView_n_items"> @@ -530,17 +531,17 @@ <string name="share">Megosztás</string> <string name="invalid_session_id">Érvénytelen Session azonosító</string> <string name="cancel">Mégse</string> - <string name="your_session_id">Az ön Session azonosítója</string> - <string name="activity_landing_title_2">Az ön Session-ja itt kezdődik...</string> + <string name="your_session_id">A session azonosítód</string> + <string name="activity_landing_title_2">A Session itt kezdődik...</string> <string name="activity_landing_register_button_title">Session azonosító létrehozása</string> - <string name="activity_landing_restore_button_title">Folytassa az ülését</string> + <string name="activity_landing_restore_button_title">Session azonosító helyreállítása</string> <string name="view_fake_chat_bubble_1">Mi az a Session?</string> <string name="view_fake_chat_bubble_2">Ez egy decentralizált, titkosított üzenetküldő alkalmazás</string> <string name="view_fake_chat_bubble_3">Tehát nem gyűjti a személyes adataimat vagy a beszélgetés metaadatait? Hogyan működik?</string> - <string name="view_fake_chat_bubble_4">Fejlett névtelen útválasztási és end-to-end titkosítási technológiák kombinációjának használata.</string> - <string name="view_fake_chat_bubble_5">A barátok nem engedik, hogy a barátok kompromittált hírnököket használjanak. Szívesen.</string> + <string name="view_fake_chat_bubble_4">Fejlett anonim útválasztási és végponttól-végpontig titkosítási technológiák használatával.</string> + <string name="view_fake_chat_bubble_5">A barátként nem engedhetem, hogy megbízhatatlan üzenetküldő appokat használj. Szívesen.</string> <string name="activity_register_title">Ismerd meg a Session ID-d</string> - <string name="activity_register_explanation">Az üles azonosító az az egyedi cím, amelyet az emberek használhatnak, hogy kapcsolatba lépjenek Önnel az Ülés során. Mivel nincs kapcsolat a valódi személyazonosságával, az Ülés azonosító teljesen névtelen, és privát.</string> + <string name="activity_register_explanation">A Session azonosító az az egyedi cím, amelyet az emberek használhatnak, hogy kapcsolatba lépjenek Önnel a Sessionon. Mivel nincs kapcsolat a valódi személyazonosságával, az Session azonosító teljesen névtelen, és privát.</string> <string name="activity_restore_title">Fiók visszaállítása</string> <string name="activity_restore_explanation">Írja be azt a helyreállítási kifejezést, amelyet a fiók visszaállításához regisztrálásakor kapott.</string> <string name="activity_restore_seed_edit_text_hint">Írja be a helyreállítási kifejezést</string> @@ -552,7 +553,7 @@ <string name="activity_pn_mode_recommended_option_tag">Ajánlott</string> <string name="activity_pn_mode_no_option_picked_dialog_title">Kérjük, válasszon egy lehetőséget</string> <string name="activity_home_empty_state_message">Még nincsenek névjegyei</string> - <string name="activity_home_empty_state_button_title">Indítson el egy ülést</string> + <string name="activity_home_empty_state_button_title">Indítson el egy beszélgetést</string> <string name="activity_home_leave_group_dialog_message">Biztosan elhagyja ezt a csoportot?</string> <string name="activity_home_leaving_group_failed_message">"Nem sikerült kilépni a csoportból"</string> <string name="activity_home_delete_conversation_dialog_message">Biztosan törli ezt a beszélgetést?</string> @@ -573,8 +574,8 @@ <string name="activity_path_destination_row_title">Célállomás</string> <string name="activity_path_learn_more_button_title">Tudj meg többet</string> <string name="activity_path_resolving_progress">Feloldás...</string> - <string name="activity_create_private_chat_title">Új Ülés</string> - <string name="activity_create_private_chat_enter_session_id_tab_title">Adja meg az Ülés azonosítóját</string> + <string name="activity_create_private_chat_title">Új Session</string> + <string name="activity_create_private_chat_enter_session_id_tab_title">Adja meg a Session azonosítóját</string> <string name="activity_create_private_chat_scan_qr_code_tab_title">QR kód beolvasása</string> <string name="activity_create_private_chat_scan_qr_code_explanation">A beszélgetés elindításához olvassa be a felhasználó QR kódját. A QR kód a fiókbeállításokban található a QR kód ikonra koppintva.</string> <string name="fragment_enter_public_key_edit_text_hint">Írja be Session azonosítóját vagy ONS nevét</string> diff --git a/app/src/main/res/values-v27/colors.xml b/app/src/main/res/values-v27/colors.xml new file mode 100644 index 0000000000..5c5e9494f1 --- /dev/null +++ b/app/src/main/res/values-v27/colors.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="classic_light_navigation_bar">@color/classic_light_6</color> + <color name="ocean_light_navigation_bar">@color/ocean_light_7</color> +</resources> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 86963c540a..6452efb83a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -18,7 +18,7 @@ </plurals> <string name="ApplicationPreferencesActivity_delete_all_old_messages_now">删除所有旧消息?</string> <plurals name="ApplicationPreferencesActivity_this_will_immediately_trim_all_conversations_to_the_d_most_recent_messages"> - <item quantity="other">这将会立刻整理所有会话到 %d 最近的信息。</item> + <item quantity="other">这将会立刻整理所有会话到 %d 最近的信息</item> </plurals> <string name="ApplicationPreferencesActivity_delete">删除</string> <string name="ApplicationPreferencesActivity_On">开启</string> @@ -29,7 +29,7 @@ <string name="DraftDatabase_Draft_video_snippet">(视频)</string> <string name="DraftDatabase_Draft_quote_snippet">(回复)</string> <!-- AttachmentManager --> - <string name="AttachmentManager_cant_open_media_selection">找不到用于选择媒体的应用。</string> + <string name="AttachmentManager_cant_open_media_selection">找不到用于选择媒体的应用</string> <string name="AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio">Session 需要\"存储空间\"权限才能发送图片、视频以及音频,但是该权限已被永久禁用。请转到系统的应用设置菜单,找到权限设置,选择 Session 应用,并启用\"存储空间\"权限。</string> <string name="AttachmentManager_signal_requires_the_camera_permission_in_order_to_take_photos_but_it_has_been_permanently_denied">Session 需要\"相机\"权限才能拍照并发送相片,但是该权限已被永久禁用。请转到系统的应用设置菜单,找到权限设置,选择 Session 应用,并启用\"相机\"权限。</string> <!-- AudioSlidePlayer --> @@ -40,14 +40,14 @@ <string name="BucketedThreadMedia_This_week">本周</string> <string name="BucketedThreadMedia_This_month">本月</string> <!-- CommunicationActions --> - <string name="CommunicationActions_no_browser_found">未找到网页浏览器。</string> + <string name="CommunicationActions_no_browser_found">未找到网页浏览器</string> <!-- ContactsCursorLoader --> <string name="ContactsCursorLoader_groups">群组</string> <!-- ConversationItem --> <string name="ConversationItem_error_not_delivered">发送失败,点击查看详情</string> - <string name="ConversationItem_received_key_exchange_message_tap_to_process">收到密钥交换消息,点击进行处理。</string> + <string name="ConversationItem_received_key_exchange_message_tap_to_process">收到密钥交换消息,点击进行处理</string> <string name="ConversationItem_click_to_approve_unencrypted">发送失败,点击使用不安全的方式发送</string> - <string name="ConversationItem_unable_to_open_media">无法找到能打开该媒体的应用。</string> + <string name="ConversationItem_unable_to_open_media">无法找到能打开该媒体的应用</string> <string name="ConversationItem_copied_text">已复制 %s</string> <string name="ConversationItem_read_more">阅读更多</string> <string name="ConversationItem_download_more">  下载更多</string> @@ -55,7 +55,7 @@ <!-- ConversationActivity --> <string name="ConversationActivity_add_attachment">添加附件</string> <string name="ConversationActivity_select_contact_info">选择联系信息</string> - <string name="ConversationActivity_sorry_there_was_an_error_setting_your_attachment">抱歉,设置附件时出错。</string> + <string name="ConversationActivity_sorry_there_was_an_error_setting_your_attachment">抱歉,设置附件时出错</string> <string name="ConversationActivity_message">消息</string> <string name="ConversationActivity_compose">撰写</string> <string name="ConversationActivity_muted_until_date">禁言至 %1$s</string> @@ -86,7 +86,7 @@ <item quantity="other">删除选择的信息?</item> </plurals> <plurals name="ConversationFragment_this_will_permanently_delete_all_n_selected_messages"> - <item quantity="other">将会永久删除所有已选择的 %1$d 条消息。</item> + <item quantity="other">将会永久删除所有已选择的 %1$d 条消息</item> </plurals> <string name="ConversationFragment_ban_selected_user">是否封禁此用户?</string> <string name="ConversationFragment_save_to_sd_card">保存到存储?</string> @@ -148,19 +148,19 @@ <string name="MediaPickerActivity_send_to">发送给%s</string> <!-- MediaSendActivity --> <string name="MediaSendActivity_add_a_caption">添加注释...</string> - <string name="MediaSendActivity_an_item_was_removed_because_it_exceeded_the_size_limit">一个附件已被移除,因为它超过了文件大小限制。</string> - <string name="MediaSendActivity_camera_unavailable">摄像头不可用。</string> + <string name="MediaSendActivity_an_item_was_removed_because_it_exceeded_the_size_limit">一个附件已被移除,因为它超过了文件大小限制</string> + <string name="MediaSendActivity_camera_unavailable">摄像头不可用</string> <string name="MediaSendActivity_message_to_s">发送给 %s 的消息</string> <plurals name="MediaSendActivity_cant_share_more_than_n_items"> - <item quantity="other">您最多分享 %d 项。</item> + <item quantity="other">您最多分享 %d 项</item> </plurals> <!-- MediaRepository --> <string name="MediaRepository_all_media">所有媒体</string> <!-- MessageRecord --> - <string name="MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported">已收到的信息使用了旧版本的 Signal 进行加密,并且已经不再被支持。请联系发送者升级 Session 到最新版本然后再次发送该信息。</string> - <string name="MessageRecord_left_group">您已经离开了此群组。</string> + <string name="MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported">已收到的信息使用了旧版本的 Signal 进行加密,并且已经不再被支持。请联系发送者升级 Session 到最新版本然后再次发送该信息</string> + <string name="MessageRecord_left_group">您已经离开了此群组</string> <string name="MessageRecord_you_updated_group">您更新了此群组</string> - <string name="MessageRecord_s_updated_group">%s 更新了此群组。</string> + <string name="MessageRecord_s_updated_group">%s 更新了此群组</string> <!-- ExpirationDialog --> <string name="ExpirationDialog_disappearing_messages">阅后即焚</string> <string name="ExpirationDialog_your_messages_will_not_expire">您的信息将不会过期。</string> @@ -169,10 +169,10 @@ <string name="PassphrasePromptActivity_enter_passphrase">输入密码</string> <!-- RecipientPreferencesActivity --> <string name="RecipientPreferenceActivity_block_this_contact_question">屏蔽此联系人?</string> - <string name="RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact">您将不再收到来自此联系人的消息和通话。</string> + <string name="RecipientPreferenceActivity_you_will_no_longer_receive_messages_and_calls_from_this_contact">您将不再收到来自此联系人的消息和通话</string> <string name="RecipientPreferenceActivity_block">屏蔽</string> <string name="RecipientPreferenceActivity_unblock_this_contact_question">解除此联系人的屏蔽?</string> - <string name="RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact">您将可以再次收到来自此联系人的信息和呼叫。</string> + <string name="RecipientPreferenceActivity_you_will_once_again_be_able_to_receive_messages_and_calls_from_this_contact">您将可以再次收到来自此联系人的信息和呼叫</string> <string name="RecipientPreferenceActivity_unblock">解除屏蔽</string> <string name="RecipientPreferenceActivity_notification_settings">通知设置</string> <!-- Slide --> @@ -182,14 +182,14 @@ <!-- SmsMessageRecord --> <string name="SmsMessageRecord_received_corrupted_key_exchange_message">收到损坏的密钥 交换消息!</string> - <string name="SmsMessageRecord_received_message_with_new_safety_number_tap_to_process">接受到了使用新的安全码的信息。点击以处理和显示。</string> + <string name="SmsMessageRecord_received_message_with_new_safety_number_tap_to_process">接受到了使用新的安全码的信息。点击以处理和显示</string> <string name="SmsMessageRecord_secure_session_reset">您重置了安全会话。</string> <string name="SmsMessageRecord_secure_session_reset_s">%s 重置了安全会话</string> <string name="SmsMessageRecord_duplicate_message">重复的信息。</string> <!-- ThreadRecord --> <string name="ThreadRecord_group_updated">群组已更新</string> <string name="ThreadRecord_left_the_group">离开此群组</string> - <string name="ThreadRecord_secure_session_reset">安全会话已重设。</string> + <string name="ThreadRecord_secure_session_reset">安全会话已重设</string> <string name="ThreadRecord_draft">草稿:</string> <string name="ThreadRecord_called">您呼叫的</string> <string name="ThreadRecord_called_you">呼叫您的</string> @@ -198,8 +198,8 @@ <string name="ThreadRecord_s_is_on_signal">%s 在 Session 上!</string> <string name="ThreadRecord_disappearing_messages_disabled">阅后即焚已禁用</string> <string name="ThreadRecord_disappearing_message_time_updated_to_s">阅后即焚时间设置为 %s</string> - <string name="ThreadRecord_s_took_a_screenshot">%s 进行了截图。</string> - <string name="ThreadRecord_media_saved_by_s">%s 保存了媒体内容。</string> + <string name="ThreadRecord_s_took_a_screenshot">%s 进行了截图</string> + <string name="ThreadRecord_media_saved_by_s">%s 保存了媒体内容</string> <string name="ThreadRecord_safety_number_changed">安全代码已改变</string> <string name="ThreadRecord_your_safety_number_with_s_has_changed">您与 %s 的安全码已经改变</string> <string name="ThreadRecord_you_marked_verified">你被标识为已验证</string> @@ -218,24 +218,24 @@ <!-- MuteDialog --> <string name="MuteDialog_mute_notifications">静音通知</string> <!-- KeyCachingService --> - <string name="KeyCachingService_signal_passphrase_cached">轻触以开启。</string> + <string name="KeyCachingService_signal_passphrase_cached">轻触以开启</string> <string name="KeyCachingService_passphrase_cached"> Session 已解锁</string> <string name="KeyCachingService_lock">锁定 Session</string> <!-- MediaPreviewActivity --> <string name="MediaPreviewActivity_you">您</string> - <string name="MediaPreviewActivity_unssuported_media_type">不支持的媒体类型。</string> + <string name="MediaPreviewActivity_unssuported_media_type">不支持的媒体类型</string> <string name="MediaPreviewActivity_draft">草稿</string> <string name="MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied">Session 需要存储权限以写入外部存储,但是该权限已经被永久拒绝。请进入应用程序设置,点击权限,并启用“存储”。</string> <string name="MediaPreviewActivity_unable_to_write_to_external_storage_without_permission">无法在没有写入到外部存储的权限时保存到外部存储</string> <string name="MediaPreviewActivity_media_delete_confirmation_title">删除消息吗?</string> - <string name="MediaPreviewActivity_media_delete_confirmation_message">这将会永久删除消息。</string> + <string name="MediaPreviewActivity_media_delete_confirmation_message">这将会永久删除消息</string> <!-- MessageNotifier --> <string name="MessageNotifier_d_new_messages_in_d_conversations">%1$d 新信息是在 %2$d 中的会话</string> <string name="MessageNotifier_most_recent_from_s">最新的邮件来自:%1$s</string> <string name="MessageNotifier_locked_message">锁定的消息</string> <string name="MessageNotifier_message_delivery_failed">信息发送失败。</string> <string name="MessageNotifier_failed_to_deliver_message">信息发送失败</string> - <string name="MessageNotifier_error_delivering_message">发送信息错误。</string> + <string name="MessageNotifier_error_delivering_message">发送信息错误</string> <string name="MessageNotifier_mark_all_as_read">全部标记为已读</string> <string name="MessageNotifier_mark_read">标记为已读</string> <string name="MessageNotifier_reply">回复</string> @@ -344,7 +344,7 @@ <!-- recipient_preferences --> <string name="recipient_preferences__block">屏蔽</string> <!-- message_details_header --> - <string name="message_details_header__issues_need_your_attention">有些问题需要您注意。</string> + <string name="message_details_header__issues_need_your_attention">有些问题需要您注意</string> <string name="message_details_header__sent">发送</string> <string name="message_details_header__received">已接收</string> <string name="message_details_header__disappears">销毁</string> @@ -426,7 +426,7 @@ <string name="preferences__incognito_keyboard">隐身键盘</string> <string name="preferences__read_receipts">已读回执</string> <string name="preferences__typing_indicators">“正在输入”提示</string> - <string name="preferences__if_typing_indicators_are_disabled_you_wont_be_able_to_see_typing_indicators">如果“正在输入”提示功能被禁用,您将无法看到别人正在输入的提示。</string> + <string name="preferences__if_typing_indicators_are_disabled_you_wont_be_able_to_see_typing_indicators">如果“正在输入”提示功能被禁用,您将无法看到别人正在输入的提示</string> <string name="preferences__request_keyboard_to_disable_personalized_learning">关闭键盘自动学习功能</string> <string name="preferences__light_theme">明亮</string> <string name="preferences__dark_theme">黑暗</string> @@ -482,19 +482,19 @@ <!-- Trimmer --> <string name="trimmer__deleting">正在删除</string> <string name="trimmer__deleting_old_messages">正在删除旧消息…</string> - <string name="trimmer__old_messages_successfully_deleted">旧消息删除成功。</string> + <string name="trimmer__old_messages_successfully_deleted">旧消息删除成功</string> <!-- transport_selection_list_item --> <string name="Permissions_permission_required">需要相应权限</string> <string name="Permissions_continue">继续</string> <string name="Permissions_not_now">不是现在</string> <string name="backup_enable_dialog__backups_will_be_saved_to_external_storage_and_encrypted_with_the_passphrase_below_you_must_have_this_passphrase_in_order_to_restore_a_backup">备份将会保存到外部存储并使用下面的密码加密。您必须使用此密码才能恢复备份。</string> - <string name="backup_enable_dialog__i_have_written_down_this_passphrase">我已记下此密码。如果没有该密码,我将无法恢复备份。</string> + <string name="backup_enable_dialog__i_have_written_down_this_passphrase">我已记下此密码。如果没有该密码,我将无法恢复备份</string> <string name="registration_activity__skip">略过</string> - <string name="RegistrationActivity_backup_failure_downgrade">不能导入来自新版本的 Session 备份。</string> + <string name="RegistrationActivity_backup_failure_downgrade">不能导入来自新版本的 Session 备份</string> <string name="RegistrationActivity_incorrect_backup_passphrase">备份密码错误</string> <string name="BackupDialog_enable_local_backups">启用本地备份吗?</string> <string name="BackupDialog_enable_backups">启用备份</string> - <string name="BackupDialog_please_acknowledge_your_understanding_by_marking_the_confirmation_check_box">请确定您已理解对话框中的文字并勾选最下方的复选框。</string> + <string name="BackupDialog_please_acknowledge_your_understanding_by_marking_the_confirmation_check_box">请确定您已理解对话框中的文字并勾选最下方的复选框</string> <string name="BackupDialog_delete_backups">删除备份吗?</string> <string name="BackupDialog_disable_and_delete_all_local_backups">关闭并且删除所有本地备份吗?</string> <string name="BackupDialog_delete_backups_statement">删除备份</string> @@ -522,17 +522,17 @@ <string name="activity_landing_register_button_title">创建Session ID</string> <string name="activity_landing_restore_button_title">继续使用您的Session ID</string> <string name="view_fake_chat_bubble_1">什么是Session?</string> - <string name="view_fake_chat_bubble_2">Session是一个去中心化的加密消息应用。</string> + <string name="view_fake_chat_bubble_2">Session是一个去中心化的加密消息应用</string> <string name="view_fake_chat_bubble_3">所以Session不会收集我的个人信息或者对话元数据?怎么做到的?</string> <string name="view_fake_chat_bubble_4">通过结合高效的匿名路由和端到端的加密技术。</string> <string name="view_fake_chat_bubble_5">好朋友之间就要使用能够保证信息安全的聊天工具,不用谢啦!</string> <string name="activity_register_title">向您的Session ID打个招呼吧</string> - <string name="activity_register_explanation">您的Session ID是其他用户在与您聊天时使用的独一无二的地址。Session ID与您的真实身份无关,它在设计上完全是匿名且私密的。</string> + <string name="activity_register_explanation">您的Session ID是其他用户在与您聊天时使用的独一无二的地址。Session ID与您的真实身份无关,它在设计上完全是匿名且私密的</string> <string name="activity_restore_title">恢复您的帐号</string> - <string name="activity_restore_explanation">在您重新登陆并需要恢复账户时,请输入您注册帐号时的恢复口令。</string> + <string name="activity_restore_explanation">在您重新登陆并需要恢复账户时,请输入您注册帐号时的恢复口令</string> <string name="activity_restore_seed_edit_text_hint">输入您的恢复口令</string> <string name="activity_display_name_title_2">选择您想显示的名称</string> - <string name="activity_display_name_explanation">这就是您在使用Session时的名字。它可以是您的真实姓名,别名或您喜欢的其他任何名称。</string> + <string name="activity_display_name_explanation">这就是您在使用Session时的名字。它可以是您的真实姓名,别名或您喜欢的其他任何名称</string> <string name="activity_display_name_edit_text_hint">输入您想显示的名称</string> <string name="activity_display_name_display_name_missing_error">请设定一个名称</string> <string name="activity_display_name_display_name_too_long_error">请设定一个较短的名称</string> @@ -550,7 +550,7 @@ <string name="activity_seed_reveal_button_title">长按显示内容</string> <string name="view_seed_reminder_title">就快完成了!80%</string> <string name="view_seed_reminder_subtitle_1">保存恢复口令以保护您的帐号安全</string> - <string name="view_seed_reminder_subtitle_2">点击并按住遮盖住的单词以显示您的恢复口令,请将它安全地存储以保护您的Session ID。</string> + <string name="view_seed_reminder_subtitle_2">点击并按住遮盖住的单词以显示您的恢复口令,请将它安全地存储以保护您的Session ID</string> <string name="view_seed_reminder_subtitle_3">请确保将恢复口令存储在安全的地方</string> <string name="activity_path_title">路径</string> <string name="activity_path_explanation">Session会通过其去中心化网络中的多个服务节点跳转消息以隐藏IP。以下国家是您目前的消息连接跳转服务节点所在地:</string> @@ -563,10 +563,10 @@ <string name="activity_create_private_chat_title">新建私人聊天</string> <string name="activity_create_private_chat_enter_session_id_tab_title">输入Session ID</string> <string name="activity_create_private_chat_scan_qr_code_tab_title">扫描二维码</string> - <string name="activity_create_private_chat_scan_qr_code_explanation">扫描其他用户的二维码来发起对话。您可以在帐号设置中点击二维码图标找到二维码。</string> + <string name="activity_create_private_chat_scan_qr_code_explanation">扫描其他用户的二维码来发起对话。您可以在帐号设置中点击二维码图标找到二维码</string> <string name="fragment_enter_public_key_edit_text_hint">输入Session ID或ONS名称</string> - <string name="fragment_enter_public_key_explanation">用户可以通过进入帐号设置并点击“共享Session ID”来分享自己的Session ID,或通过共享其二维码来分享其Session ID。</string> - <string name="fragment_enter_public_key_error_message">请检查会话 ID 或 ONS 名称,然后重试。</string> + <string name="fragment_enter_public_key_explanation">用户可以通过进入帐号设置并点击“共享Session ID”来分享自己的Session ID,或通过共享其二维码来分享其Session ID</string> + <string name="fragment_enter_public_key_error_message">请检查会话 ID 或 ONS 名称,然后重试</string> <string name="fragment_scan_qr_code_camera_access_explanation">Session需要摄像头访问权限才能扫描二维码</string> <string name="fragment_scan_qr_code_grant_camera_access_button_title">授予摄像头访问权限</string> <string name="activity_create_closed_group_title">创建私密群组</string> @@ -603,13 +603,13 @@ <string name="activity_privacy_settings_title">隐私</string> <string name="preferences_notifications_strategy_category_title">通知选项</string> <string name="preferences_notifications_strategy_category_fast_mode_title">使用快速模式</string> - <string name="preferences_notifications_strategy_category_fast_mode_summary">新消息将通过 Google 通知服务器即时可靠地发送。</string> + <string name="preferences_notifications_strategy_category_fast_mode_summary">新消息将通过 Google 通知服务器即时可靠地发送</string> <string name="fragment_device_list_bottom_sheet_change_name_button_title">更换名称</string> <string name="fragment_device_list_bottom_sheet_unlink_device_button_title">断开设备关联</string> <string name="dialog_seed_title">您的恢复口令</string> - <string name="dialog_seed_explanation">这是您的恢复口令。您可以通过该口令将Session ID还原或迁移到新设备上。</string> + <string name="dialog_seed_explanation">这是您的恢复口令。您可以通过该口令将Session ID还原或迁移到新设备上</string> <string name="dialog_clear_all_data_title">清除所有数据</string> - <string name="dialog_clear_all_data_explanation">这将永久删除您的消息、对话和联系人。</string> + <string name="dialog_clear_all_data_explanation">这将永久删除您的消息、对话和联系人</string> <string name="dialog_clear_all_data_network_explanation">你想只清除这个设备,还是删除你的整个账户?</string> <string name="dialog_clear_all_data_local_only">仅删除</string> <string name="dialog_clear_all_data_clear_network">整个账户</string> @@ -618,7 +618,7 @@ <string name="activity_qr_code_view_scan_qr_code_tab_title">扫描二维码</string> <string name="activity_qr_code_view_scan_qr_code_explanation">扫描对方的二维码以发起对话</string> <string name="fragment_view_my_qr_code_title">扫描我的二维码</string> - <string name="fragment_view_my_qr_code_explanation">这是您的二维码。其他用户可以对其进行扫描以发起与您的对话。</string> + <string name="fragment_view_my_qr_code_explanation">这是您的二维码。其他用户可以对其进行扫描以发起与您的对话</string> <string name="fragment_view_my_qr_code_share_title">分享二维码</string> <string name="fragment_contact_selection_contacts_title">联系人</string> <string name="fragment_contact_selection_closed_groups_title">私密群组</string> @@ -645,23 +645,23 @@ <string name="attachment">附件</string> <string name="attachment_type_voice_message">语音消息</string> <string name="details">详细信息</string> - <string name="dialog_backup_activation_failed">无法激活备份,请稍后重试或联系客服。</string> + <string name="dialog_backup_activation_failed">无法激活备份,请稍后重试或联系客服</string> <string name="activity_backup_restore_title">恢复备份</string> <string name="activity_backup_restore_select_file">选择文件</string> - <string name="activity_backup_restore_explanation_1">选择一个备份文件,并输入创建该文件时使用的密码。</string> + <string name="activity_backup_restore_explanation_1">选择一个备份文件,并输入创建该文件时使用的密码</string> <string name="activity_backup_restore_passphrase">30位数的密码</string> <string name="activity_link_device_skip_prompt">这需要一点时间,您想要跳过吗?</string> <string name="activity_link_device_link_device">关联设备</string> <string name="activity_link_device_recovery_phrase">恢复口令</string> <string name="activity_link_device_scan_qr_code">扫描二维码</string> - <string name="activity_link_device_qr_message">在您的其他设备上导航到设置 -> 恢复口令以显示您的 QR 代码。</string> + <string name="activity_link_device_qr_message">在您的其他设备上导航到设置 -> 恢复口令以显示您的 QR 代码</string> <string name="activity_join_public_chat_join_rooms">或加入下列群组…</string> <string name="activity_pn_mode_message_notifications">消息通知</string> - <string name="activity_pn_mode_explanation">我们有两种方式来向您发送消息通知。</string> + <string name="activity_pn_mode_explanation">我们有两种方式来向您发送消息通知</string> <string name="activity_pn_mode_fast_mode">快速模式</string> <string name="activity_pn_mode_slow_mode">慢速模式</string> - <string name="activity_pn_mode_fast_mode_explanation">新消息将通过 Google 通知服务器即时可靠地发送。</string> - <string name="activity_pn_mode_slow_mode_explanation">Session 将不时在后台获取新消息。</string> + <string name="activity_pn_mode_fast_mode_explanation">新消息将通过 Google 通知服务器即时可靠地发送</string> + <string name="activity_pn_mode_slow_mode_explanation">Session 将不时在后台获取新消息</string> <string name="fragment_recovery_phrase_title">恢复口令</string> <string name="activity_prompt_passphrase_session_locked">Session 已锁定</string> <string name="activity_prompt_passphrase_tap_to_unlock">点击解锁</string> @@ -677,18 +677,18 @@ <string name="open">打开</string> <string name="copy_url">复制链接</string> <string name="dialog_link_preview_title">是否启用链接预览?</string> - <string name="dialog_link_preview_explanation">链接预览将为您发送或收到的URL生成预览内容。 该功能非常实用,但Session需要访问该链接指向的网站以生成预览。您可以随后在会话设置中禁用该功能。</string> + <string name="dialog_link_preview_explanation">链接预览将为您发送或收到的URL生成预览内容。 该功能非常实用,但Session需要访问该链接指向的网站以生成预览。您可以随后在会话设置中禁用该功能</string> <string name="dialog_link_preview_enable_button_title">启用</string> <string name="dialog_download_title">是否信任 %s?</string> <string name="dialog_download_explanation">您确定要下载%s发送的媒体消息吗?</string> <string name="dialog_download_button_title">下载</string> <string name="activity_conversation_blocked_banner_text">%s 已被屏蔽。是否解除屏蔽?</string> - <string name="activity_conversation_attachment_prep_failed">准备发送附件失败。</string> + <string name="activity_conversation_attachment_prep_failed">准备发送附件失败</string> <string name="media">媒体文件</string> <string name="UntrustedAttachmentView_download_attachment">点击下载 %s</string> <string name="message_details_header__error">错误</string> <string name="dialog_send_seed_title">警告</string> - <string name="dialog_send_seed_explanation">这是您的恢复口令。如果您将其发送给某人,他们将完全有权访问您的帐户。</string> + <string name="dialog_send_seed_explanation">这是您的恢复口令。如果您将其发送给某人,他们将完全有权访问您的帐户</string> <string name="dialog_send_seed_send_button_title">发送</string> <string name="notify_type_all">所有的</string> <string name="notify_type_mentions">提到我的</string> diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 566ee7e36c..8d0d714e49 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -169,14 +169,6 @@ <item>@string/arrays__use_custom</item> </string-array> - <string-array name="mute_durations"> - <item>@string/arrays__mute_for_one_hour</item> - <item>@string/arrays__mute_for_two_hours</item> - <item>@string/arrays__mute_for_one_day</item> - <item>@string/arrays__mute_for_seven_days</item> - <item>@string/arrays__mute_forever</item> - </string-array> - <string-array name="pref_notification_privacy_entries"> <item>@string/arrays__name_and_message</item> <item>@string/arrays__name_only</item> diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 888b0af633..ae38d0c057 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -28,6 +28,7 @@ <attr name="ic_visibility_on" format="reference" /> <attr name="ic_visibility_off" format="reference" /> + <attr name="accentColor" format="reference|color"/> <attr name="prominentButtonColor" format="reference|color"/> <attr name="elementBorderColor" format="reference|color"/> <attr name="conversation_background" format="reference|color"/> @@ -150,7 +151,6 @@ <attr name="conversation_shadow_main" format="color|reference"/> <attr name="default_background_start" format="color|reference"/> <attr name="default_background_end" format="color|reference"/> - <attr name="colorCellRipple" format="color|reference"/> <attr name="colorCellBackground" format="color|reference" /> <attr name="colorSettingsBackground" format="color|reference" /> <attr name="colorDividerBackground" format="color|reference" /> @@ -164,6 +164,7 @@ <attr name="message_received_text_color" format="color|reference" /> <attr name="message_sent_background_color" format="color|reference" /> <attr name="message_sent_text_color" format="color|reference" /> + <attr name="message_status_color" format="color|reference" /> <attr name="input_bar_background" format="color|reference"/> <attr name="input_bar_text_hint" format="color|reference"/> <attr name="input_bar_text_user" format="color|reference"/> @@ -175,7 +176,6 @@ <attr name="input_bar_lock_view_background" format="color|reference"/> <attr name="input_bar_lock_view_border" format="color|reference"/> <attr name="mention_candidates_view_background" format="color|reference"/> - <attr name="mention_candidates_view_background_ripple" format="color|reference"/> <attr name="scroll_to_bottom_button_background" format="color|reference"/> <attr name="scroll_to_bottom_button_border" format="color|reference"/> <attr name="conversation_unread_count_indicator_background" format="color|reference"/> @@ -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 3fd16ffc67..eae5a2b167 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -60,6 +60,9 @@ <color name="transparent_white_40">#40ffffff</color> <color name="transparent_white_aa">#aaffffff</color> + <color name="navigation_bar">@color/compose_view_background</color> + <color name="classic_light_navigation_bar">@color/navigation_bar</color> + <color name="ocean_light_navigation_bar">@color/navigation_bar</color> <color name="action_mode_status_bar">@color/gray65</color> <color name="touch_highlight">#22000000</color> @@ -141,22 +144,27 @@ <color name="ocean_accent">#57C9FA</color> - <color name="ocean_dark_0">#111111</color> + <color name="ocean_dark_0">#000000</color> <color name="ocean_dark_1">#1A1C28</color> <color name="ocean_dark_2">#252735</color> <color name="ocean_dark_3">#2B2D40</color> <color name="ocean_dark_4">#3D4A5D</color> <color name="ocean_dark_5">#A6A9CE</color> - <color name="ocean_dark_6">#FFFFFF</color> + <color name="ocean_dark_6">#5CAACC</color> + <color name="ocean_dark_7">#FFFFFF</color> - <color name="ocean_light_0">#19345D</color> - <color name="ocean_light_1">#6A6E90</color> - <color name="ocean_light_2">#5CAACC</color> - <color name="ocean_light_3">#B3EDF2</color> - <color name="ocean_light_4">#E7F3F4</color> - <color name="ocean_light_5">#ECFAFB</color> - <color name="ocean_light_6">#FCFFFF</color> + <color name="ocean_light_0">#000000</color> + <color name="ocean_light_1">#19345D</color> + <color name="ocean_light_2">#6A6E90</color> + <color name="ocean_light_3">#5CAACC</color> + <color name="ocean_light_4">#B3EDF2</color> + <color name="ocean_light_5">#E7F3F4</color> + <color name="ocean_light_6">#ECFAFB</color> + <color name="ocean_light_7">#FCFFFF</color> - <color name="danger">#EA5545</color> + <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 a28637306b..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> @@ -18,7 +20,7 @@ <dimen name="very_small_profile_picture_size">26dp</dimen> <dimen name="small_profile_picture_size">36dp</dimen> <dimen name="medium_profile_picture_size">46dp</dimen> - <dimen name="large_profile_picture_size">76dp</dimen> + <dimen name="large_profile_picture_size">80dp</dimen> <dimen name="conversation_view_status_indicator_size">14dp</dimen> <dimen name="border_thickness">1dp</dimen> <dimen name="new_conversation_button_collapsed_size">60dp</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 8bde853c73..640b9f005d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,11 +4,167 @@ <string name="yes">Yes</string> <string name="no">No</string> <string name="delete">Delete</string> + <string name="resend">Resend</string> + <string name="reply">Reply</string> <string name="ban">Ban</string> <string name="please_wait">Please wait…</string> <string name="save">Save</string> + <string name="image">Image</string> <string name="note_to_self">Note to Self</string> <string name="version_s">Version %s</string> + <string name="expand">Expand</string> +<!--Accessibility ID's--> + <!-- Landing Page --> + <string name="AccessibilityId_create_session_id">Create session ID</string> + <string name="AccessibilityId_restore_your_session">Restore Your Session</string> + <string name="AccessibilityId_link_a_device">Link a device</string> + <string name="AccessibilityId_link_device">Link Device</string> + <!-- Session ID Page--> + <string name="AccessibilityId_session_id">Session ID</string> + <string name="AccessibilityId_recovery_phrase_reminder">Recovery phrase reminder</string> + <string name="AccessibilityId_continue">Continue</string> + <!-- Recovery phrase input --> + + <string name="AccessibilityId_copy_session_id">Copy Session ID</string> + <!--Restore by seed page--> + <string name="AccessibilityId_enter_your_recovery_phrase">Enter your recovery phrase</string> + <!--Create Display Name --> + <string name="AccessibilityId_enter_display_name">Enter display name</string> + <!--Message Notifications Options--> + <string name="AccessibilityId_message_notifications">Message Notifications</string> + <string name="AccessibilityId_fast_mode_notifications_option">Fast mode notifications option</string> + <string name="AccessibilityId_slow_mode_notifications_option">Slow mode notifications option</string> + <string name="AccessibilityId_continue_message_notifications">Continue with settings</string> + <!--Recovery Phrase reminder--> + <string name="AccessibilityId_recovery_phrase">Recovery Phrase</string> + <string name="AccessibilityId_copy_recovery_phrase">Copy Recovery Phrase</string> + <!--Home Page--> + <string name="AccessibilityId_profile_picture">User settings</string> + <string name="AccessibilityId_search_icon">Search icon</string> + <string name="AccessibilityId_conversation_list_item">Conversation list item</string> + <string name="AccessibilityId_details">Details</string> + <string name="AccessibilityId_pin">Pin</string> + <!--Settings Page --> + <string name="AccessibilityId_blocked_contacts">Blocked contacts</string> + + <!--Message requests--> + <string name="AccessibilityId_message_request_banner">Message requests banner</string> + <string name="AccessibilityId_message_request">Message request</string> + <string name="AccessibilityId_empty_message_request_folder">No pending message requests</string> + <string name="AccessibilityId_clear_all_message_requests">Clear all</string> + <!--New conversation pop up--> + <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 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> + <string name="AccessibilityId_add_to_home_screen">Add to home screen</string> + <string name="AccessibilityId_disappearing_messages">Disappearing messages</string> + <string name="AccessibilityId_block">Block</string> + <string name="AccessibilityId_block_confirm">Confirm block</string> + <string name="AccessibilityId_notification_settings">Notification settings</string> + <string name="AccessibilityId_mute_notifications">Mute notifications</string> + <string name="AccessibilityId_delete">Delete</string> + <string name="AccessibilityId_enable">Enable</string> + <!-- Conversation options for closed group--> + <string name="AccessibilityId_edit_group">Edit group</string> + <string name="AccessibilityId_leave_group">Leave group</string> + <string name="AccessibilityId_group_name">Group name</string> + <string name="AccessibilityId_accept_name_change">Accept name change</string> + <string name="AccessibilityId_cancel_name_change">Cancel name change</string> + <string name="AccessibilityId_apply_changes">Apply changes</string> + <string name="AccessibilityId_add_members">Add members</string> + <string name="AccessibilityId_done">Done</string> + <string name="AccessibilityId_mentions_list">Mentions list</string> + <string name="AccessibilityId_contact_mentions">Contact mentions</string> + <!-- Conversation icons --> + <string name="AccessibilityId_call_button">Call button</string> + <string name="AccessibilityId_settings">Settings</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">Control message</string> + <string name="AccessibilityId_blocked_banner">Blocked banner</string> + <string name="AccessibilityId_blocked_banner_text">Blocked banner text</string> + <!--New Session --> + <string name="AccessibilityId_session_id_input">Session id input box</string> + <string name="AccessibilityId_next">Next</string> + <!-- New group --> + <string name="AccessibilityId_group_name_input">Group name input</string> + <string name="AccessibilityId_continue_group_creation">Continue group creation</string> + <string name="AccessibilityId_contact">Contact</string> + <string name="AccessibilityId_select_contact">Select contact</string> + + <!--Conversation screen --> + <string name="AccessibilityId_message_input">Message input box</string> + <string name="AccessibilityId_microphone_button">New voice message</string> + <string name="AccessibilityId_send_message_button">Send message button</string> + <string name="AccessibilityId_attachments_button">Attachments button</string> + <string name="AccessibilityId_select_camera_button">Select camera button</string> + <string name="AccessibilityId_images_folder">Images folder</string> + <string name="AccessibilityId_documents_folder">Documents folder</string> + <string name="AccessibilityId_gif_button">GIF button</string> + <string name="AccessibilityId_untrusted_attachment_message">Untrusted attachment message</string> + <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">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_voice_message">Voice message</string> + <string name="AccessibilityId_document">Document</string> + <string name="AccessibilityId_deleted_message">Deleted message</string> + <string name="AccessibilityId_delete_message">Delete message</string> + <string name="AccessibilityId_reply_message">Reply to message</string> + <string name="AccessibilityId_select">Select</string> + <string name="AccessibilityId_save_attachment">Save attachment</string> + <!-- Delete message modal--> + <string name="AccessibilityId_delete_just_for_me">Delete just for me</string> + <string name="AccessibilityId_delete_for_everyone">Delete for everyone</string> + <string name="AccessibilityId_cancel_deletion">Cancel deletion</string> + <!--Settings Page--> + <string name="AccessibilityId_username_input">Username input</string> + <string name="AccessibilityId_username_text">Username text</string> + <string name="AccessibilityId_user_settings">User settings</string> + <string name="AccessibilityId_username">Username</string> + <string name="AccessibilityId_privacy">Privacy</string> + <string name="AccessibilityId_show_recovery_phrase">Show recovery phrase</string> + <string name="AccessibilityId_edit_user_nickname">Edit user nickname</string> + <string name="AccessibilityId_apply">Apply</string> + <string name="AccessibilityId_cancel">Cancel</string> + <string name="AccessibilityId_message_user">Message user</string> + <string name="AccessibilityId_notifications">Notifications</string> + <string name="AccessibilityId_conversations">Conversations</string> + <string name="AccessibilityId_message_requests">Message requests</string> + <string name="AccessibilityId_appearance">Appearance</string> + <string name="AccessibilityId_invite_friend">Invite a friend</string> + <string name="AccessibilityId_help">Help</string> + <string name="AccessibilityId_clear_data">Clear data</string> + <!-- Recovery Phrase Dialog--> + <string name="AccessibilityId_cancel_button">Cancel</string> + <!-- 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 --> @@ -77,6 +233,7 @@ <string name="ConversationActivity_attachment_exceeds_size_limits">Attachment exceeds size limits for the type of message you\'re sending.</string> <string name="ConversationActivity_unable_to_record_audio">Unable to record audio!</string> <string name="ConversationActivity_there_is_no_app_available_to_handle_this_link_on_your_device">There is no app available to handle this link on your device.</string> + <string name="ConversationActivity_copy_open_group_url">Copy Community URL</string> <string name="ConversationActivity_invite_to_open_group">Add members</string> <string name="ConversationActivity_to_send_audio_messages_allow_signal_access_to_your_microphone">Session needs microphone access to send audio messages.</string> <string name="ConversationActivity_signal_requires_the_microphone_permission_in_order_to_send_audio_messages">Session needs microphone access to send audio messages, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Microphone\".</string> @@ -87,6 +244,7 @@ <string name="ConversationActivity_search_position">%1$d of %2$d</string> <string name="ConversationActivity_call_title">Call Permissions Required</string> <string name="ConversationActivity_call_prompt">You can enable the \'Voice and video calls\' permission in the Privacy Settings.</string> + <string name="ConversationActivity_send_after_approval">You will be able to send voice messages and attachments once the recipient has approved this message request</string> <!-- ConversationFragment --> <plurals name="ConversationFragment_delete_selected_messages"> <item quantity="one">Delete selected message?</item> @@ -321,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> @@ -374,6 +533,12 @@ <string name="message_details_header__to">To:</string> <string name="message_details_header__from">From:</string> <string name="message_details_header__with">With:</string> + <string name="message_details_header__file_id">File Id:</string> + <string name="message_details_header__file_type">File Type:</string> + <string name="message_details_header__file_size">File Size:</string> + <string name="message_details_header__resolution">Resolution:</string> + <string name="message_details_header__duration">Duration:</string> + <!-- AndroidManifest.xml --> <string name="AndroidManifest__create_passphrase">Create passphrase</string> <string name="AndroidManifest__select_contacts">Select contacts</string> @@ -404,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> @@ -475,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 --> <!-- **************************************** --> @@ -486,6 +660,7 @@ <string name="conversation_context__menu_delete_message">Delete message</string> <string name="conversation_context__menu_ban_user">Ban user</string> <string name="conversation_context__menu_ban_and_delete_all">Ban and delete all</string> + <string name="conversation_context__menu_resync_message">Resync message</string> <string name="conversation_context__menu_resend_message">Resend message</string> <string name="conversation_context__menu_reply">Reply</string> <string name="conversation_context__menu_reply_to_message">Reply to message</string> @@ -626,6 +801,9 @@ <string name="activity_join_public_chat_scan_qr_code_explanation">Scan the QR code of the open group you\'d like to join</string> <string name="fragment_enter_chat_url_edit_text_hint">Enter an open group URL</string> <string name="activity_settings_title">Settings</string> + <string name="activity_settings_set_display_picture">Set display picture</string> + <string name="activity_settings_upload">Upload</string> + <string name="activity_settings_remove">Remove</string> <string name="activity_settings_display_name_edit_text_hint">Enter a display name</string> <string name="activity_settings_display_name_missing_error">Please pick a display name</string> <string name="activity_settings_display_name_too_long_error">Please pick a shorter display name</string> @@ -731,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> @@ -864,4 +1047,39 @@ <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> + <string name="delivery_status_sending">Sending</string> + <string name="delivery_status_read">Read</string> + <string name="delivery_status_sent">Sent</string> + <string name="delivery_status_sync_failed">Failed to sync</string> + <string name="delivery_status_failed">Failed to send</string> + <string name="giphy_permission_title">Search GIFs?</string> + <string name="giphy_permission_message">Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.</string> + <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 c97cb9e740..412eec96db 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> -<resources xmlns:tools="http://schemas.android.com/tools"> +<resources> <!-- Session --> <style name="Widget.Session.ActionBar" parent="Widget.AppCompat.Light.ActionBar.Solid"> @@ -23,16 +23,14 @@ </style> <style name="ThemeOverlay.Session.AlertDialog" parent="ThemeOverlay.AppCompat.Dialog.Alert"> - <item name="android:background">@drawable/default_dialog_background</item> + <item name="android:windowBackground">@drawable/default_dialog_background</item> + <item name="android:colorBackground">?attr/dialog_background_color</item> <item name="dialog_background_color">?colorPrimary</item> <item name="android:colorBackgroundFloating">?colorPrimary</item> <item name="backgroundTint">?colorPrimary</item> <item name="android:backgroundDimEnabled">true</item> <item name="android:backgroundDimAmount">0.6</item> - <item name="buttonBarNegativeButtonStyle">@style/Widget.Session.AlertDialog.NegativeButtonStyle</item> - <item name="buttonBarPositiveButtonStyle">@style/Widget.Session.AlertDialog.PositiveButtonStyle</item> <item name="android:windowContentOverlay">@null</item> - <item name="android:windowBackground">@null</item> <item name="textColorAlertDialogListItem">?android:textColorPrimary</item> </style> @@ -46,14 +44,6 @@ <item name="android:background">@drawable/default_bottom_sheet_background</item> </style> - <style name="Widget.Session.AlertDialog.NegativeButtonStyle" parent="Widget.AppCompat.Button.ButtonBar.AlertDialog"> - <item name="android:textColor">?colorAccent</item> - </style> - - <style name="Widget.Session.AlertDialog.PositiveButtonStyle" parent="Widget.AppCompat.Button.ButtonBar.AlertDialog"> - <item name="android:textColor">?colorAccent</item> - </style> - <style name="Widget.Session.AppBarLayout" parent="@style/Widget.Design.AppBarLayout"> </style> @@ -66,7 +56,6 @@ <item name="elevation">1dp</item> <item name="tabIndicatorColor">@color/transparent</item> <item name="tabIndicatorHeight">@dimen/accent_line_thickness</item> - <item name="tabRippleColor">@color/cell_selected</item> <item name="tabTextAppearance">@style/TextAppearance.Session.Tab</item> </style> @@ -80,8 +69,13 @@ <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> <item name="android:textAllCaps">false</item> <item name="android:textSize">@dimen/medium_font_size</item> <item name="android:fontFamily">sans-serif-medium</item> @@ -93,59 +87,45 @@ <style name="Widget.Session.Button.Common.ProminentFilled"> <item name="android:background">@drawable/prominent_filled_button_medium_background</item> <item name="android:textColor">@color/black</item> - <item name="android:drawableTint" tools:ignore="NewApi">?android:textColorPrimary</item> + </style> + + <style name="Widget.Session.Button.Common.Filled"> + <item name="android:background">@drawable/filled_button_medium_background</item> + <item name="android:textColor">?android:textColorPrimary</item> </style> <style name="Widget.Session.Button.Common.ProminentOutline"> <item name="android:background">@drawable/prominent_outline_button_medium_background</item> - <item name="android:textColorPrimary">?attr/prominentButtonColor</item> - <item name="android:drawableTint" tools:ignore="NewApi">?attr/prominentButtonColor</item> - </style> - - <style name="Widget.Session.Button.Common.UnimportantFilled"> - <item name="android:background">@drawable/unimportant_filled_button_medium_background</item> - <item name="android:textColor">?android:textColorPrimary</item> - <item name="android:drawableTint" tools:ignore="NewApi">?android:textColorPrimary</item> + <item name="android:textColor">@color/prominent_button_color</item> </style> <style name="Widget.Session.Button.Common.UnimportantOutline"> <item name="android:background">@drawable/unimportant_outline_button_medium_background</item> <item name="android:textColor">?android:textColorPrimary</item> - <item name="android:drawableTint" tools:ignore="NewApi">?android:textColorPrimary</item> </style> - <style name="Widget.Session.Button.Common.UnimportantDestructive"> - <item name="android:background">@drawable/unimportant_outline_button_medium_background</item> + <style name="Widget.Session.Button.Common.Borderless"> + <item name="android:background">@drawable/borderless_button_medium_background</item> <item name="android:textColor">?android:textColorPrimary</item> - <item name="android:backgroundTint" tools:ignore="NewApi">@color/destructive</item> - <item name="android:drawableTint" tools:ignore="NewApi">@color/destructive</item> </style> <style name="Widget.Session.Button.Common.DestructiveOutline"> <item name="android:background">@drawable/destructive_outline_button_medium_background</item> - <item name="android:textColor">@color/destructive</item> - <item name="android:drawableTint" tools:ignore="NewApi">?android:textColorPrimary</item> + <item name="android:textColor">@color/button_destructive</item> </style> <style name="Widget.Session.Button.Dialog" parent=""> + <item name="android:gravity">center</item> <item name="android:textAllCaps">false</item> <item name="android:textSize">@dimen/small_font_size</item> <item name="android:textColor">?android:textColorPrimary</item> - </style> - - <style name="Widget.Session.Button.Dialog.Unimportant"> - <item name="android:background">@drawable/unimportant_dialog_button_background</item> + <item name="android:textStyle">bold</item> </style> <style name="Widget.Session.Button.Dialog.UnimportantText"> <item name="android:background">@drawable/unimportant_dialog_text_button_background</item> </style> - - <style name="Widget.Session.Button.Dialog.Destructive"> - <item name="android:background">@drawable/destructive_dialog_button_background</item> - <item name="android:textColor">@color/black</item> - </style> - + <style name="Widget.Session.Button.Dialog.DestructiveText"> <item name="android:background">@drawable/destructive_dialog_text_button_background</item> <item name="android:textColor">@color/destructive</item> @@ -154,6 +134,7 @@ <style name="Widget.Session.EditText.Compose" parent="@style/Signal.Text.Body"> <item name="android:padding">2dp</item> <item name="android:background">@null</item> + <item name="android:textColor">@color/white</item> <item name="android:maxLines">4</item> <item name="android:maxLength">65536</item> <item name="android:capitalize">sentences</item> @@ -213,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> @@ -227,11 +219,13 @@ <style name="FakeChatViewMessageBubble.Incoming"> <item name="android:background">@drawable/fake_chat_view_incoming_message_background</item> <item name="android:elevation">10dp</item> + <item name="android:textColor">?message_received_text_color</item> </style> <style name="FakeChatViewMessageBubble.Outgoing"> <item name="android:background">@drawable/fake_chat_view_outgoing_message_background</item> <item name="android:elevation">10dp</item> + <item name="android:textColor">?message_sent_text_color</item> </style> <!-- Session --> @@ -286,7 +280,7 @@ <item name="android:padding">@dimen/normal_padding</item> <item name="android:gravity">center_vertical</item> <item name="android:selectable">true</item> - <item name="android:foreground">?attr/selectableItemBackground</item> + <item name="android:background">?attr/selectableItemBackground</item> </style> <style name="PopupMenu.ConversationItem" parent="@style/Widget.AppCompat.PopupMenu"> diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 4c7dfd1db4..428d37c5ef 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -54,6 +54,7 @@ <item name="menu_unpin_icon">@drawable/ic_outline_pin_off_24</item> <item name="menu_mark_all_as_read">@drawable/ic_outline_mark_chat_read_24</item> <item name="emoji_show_less_icon">@drawable/ic_chevron_up_light</item> + <item name="accentColor">?colorAccent</item> <item name="prominentButtonColor">?colorAccent</item> <item name="attachment_document_icon_small">@drawable/ic_document_small_dark</item> <item name="attachment_document_icon_large">@drawable/ic_document_large_dark</item> @@ -246,12 +247,8 @@ <item name="windowActionModeOverlay">true</item> </style> - <style name="Theme.TextSecure.Dialog.Rationale" parent="Theme.AppCompat.DayNight.Dialog.Alert"> - <item name="android:windowBackground">@drawable/default_dialog_background</item> - </style> - <style name="Theme.TextSecure.Dialog.MediaSendProgress" parent="@android:style/Theme.Dialog"> - <item name="android:background">@drawable/default_dialog_background</item> + <item name="android:colorBackground">?attr/dialog_background_color</item> <item name="android:windowNoTitle">true</item> </style> @@ -277,7 +274,7 @@ <item name="android:textColorSecondary">?android:textColorPrimary</item> </style> <style name="Ocean.Light.BottomSheet" parent="Theme.MaterialComponents.BottomSheetDialog"> - <item name="colorPrimary">@color/ocean_light_5</item> + <item name="colorPrimary">@color/ocean_light_6</item> <item name="bottomSheetStyle">@style/Widget.Session.BottomSheetDialog</item> <item name="dialog_border">@color/transparent_black_15</item> <item name="android:textColorPrimary">@color/black</item> @@ -303,6 +300,7 @@ <item name="dividerHorizontal">?dividerVertical</item> <item name="message_received_background_color">#F2F2F2</item> <item name="colorAccent">@color/classic_accent</item> + <item name="tabStyle">@style/Widget.Session.TabLayout</item> </style> <style name="Ocean"> @@ -310,10 +308,350 @@ <item name="dividerHorizontal">?dividerVertical</item> <item name="message_received_background_color">#F2F2F2</item> <item name="colorAccent">@color/ocean_accent</item> + <item name="tabStyle">@style/Widget.Session.TabLayout</item> </style> <style name="Classic.Dark"> <!-- Main styles --> + <item name="sessionLogoTint">@color/classic_dark_6</item> + <item name="colorPrimary">@color/classic_dark_0</item> + <item name="colorPrimaryDark">@color/classic_dark_0</item> + <item name="colorControlNormal">?android:textColorPrimary</item> + <item name="colorControlActivated">?colorAccent</item> + <item name="android:textColorPrimary">@color/classic_dark_6</item> + <item name="android:textColorSecondary">?android:textColorPrimary</item> + <item name="android:textColorTertiary">@color/classic_dark_5</item> + <item name="android:textColor">?android:textColorPrimary</item> + <item name="android:textColorHint">@color/gray27</item> + <item name="android:windowBackground">?colorPrimary</item> + <item name="android:navigationBarColor">@color/navigation_bar</item> + <item name="dialog_background_color">@color/classic_dark_1</item> + <item name="bottomSheetDialogTheme">@style/Classic.Dark.BottomSheet</item> + <item name="actionMenuTextColor">?android:textColorPrimary</item> + <item name="popupTheme">?actionBarPopupTheme</item> + <item name="colorCellBackground">@color/classic_dark_1</item> + <item name="colorSettingsBackground">@color/classic_dark_1</item> + <item name="colorDividerBackground">@color/classic_dark_3</item> + <item name="android:colorControlHighlight">@color/classic_dark_3</item> + <item name="colorControlHighlight">@color/classic_dark_3</item> + <item name="actionBarPopupTheme">@style/Dark.Popup</item> + <item name="actionBarWidgetTheme">@null</item> + <item name="actionBarTheme">@style/ThemeOverlay.AppCompat.Dark.ActionBar</item> + <item name="actionBarStyle">@style/Widget.Session.ActionBar</item> + <item name="accentColor">?colorAccent</item> + <item name="prominentButtonColor">?colorAccent</item> + <item name="elementBorderColor">@color/classic_dark_3</item> + + <item name="isLightTheme">false</item> + + <!-- Home screen --> + <item name="searchBackgroundColor">#1B1B1B</item> + <item name="searchIconColor">#E5E5E8</item> + <item name="searchHintColor">@color/classic_dark_5</item> + <item name="searchTextColor">?android:textColorPrimary</item> + <item name="searchHighlightTint">?colorAccent</item> + <item name="home_gradient_start">#00000000</item> + <item name="home_gradient_end">@color/classic_dark_1</item> + <item name="conversation_pinned_background_color">?colorCellBackground</item> + <item name="conversation_unread_background_color">@color/classic_dark_2</item> + <item name="conversation_pinned_icon_color">?android:textColorSecondary</item> + <item name="unreadIndicatorBackgroundColor">@color/classic_dark_3</item> + <item name="unreadIndicatorTextColor">@color/classic_dark_6</item> + + <!-- New conversation button --> + <item name="conversation_color_non_main">@color/classic_dark_2</item> + <item name="conversation_shadow_non_main">@color/transparent_black_30</item> + <item name="conversation_shadow_main">?colorAccent</item> + <item name="conversation_menu_background_color">@color/classic_dark_1</item> + <item name="conversation_menu_cell_color">?conversation_menu_background_color</item> + <item name="conversation_menu_border_color">@color/classic_dark_3</item> + <item name="conversationMenuSearchBackgroundColor">@color/classic_dark_0</item> + + <!-- Conversation --> + <item name="message_received_background_color">@color/classic_dark_3</item> + <item name="message_received_text_color">@color/classic_dark_6</item> + <item name="message_sent_background_color">?colorAccent</item> + <item name="message_sent_text_color">@color/classic_dark_0</item> + <item name="message_status_color">@color/classic_dark_5</item> + <item name="input_bar_background">@color/classic_dark_1</item> + <item name="input_bar_text_hint">@color/classic_dark_5</item> + <item name="input_bar_text_user">@color/classic_dark_6</item> + <item name="input_bar_border">@color/classic_dark_3</item> + <item name="input_bar_button_background">@color/classic_dark_2</item> + <item name="input_bar_button_text_color">@color/classic_dark_6</item> + <item name="input_bar_button_background_opaque">@color/classic_dark_2</item> + <item name="input_bar_button_background_opaque_border">@color/classic_dark_3</item> + <item name="input_bar_lock_view_background">@color/classic_dark_2</item> + <item name="input_bar_lock_view_border">@color/classic_dark_3</item> + <item name="mention_candidates_view_background">@color/classic_dark_2</item> + <item name="scroll_to_bottom_button_background">@color/classic_dark_1</item> + <item name="scroll_to_bottom_button_border">@color/classic_dark_3</item> + <item name="conversation_unread_count_indicator_background">@color/classic_dark_4</item> + <item name="message_selected">@color/classic_dark_2</item> + </style> + + <style name="Classic.Light"> + <!-- Main styles --> + <item name="sessionLogoTint">@color/classic_light_0</item> + <item name="colorPrimary">@color/classic_light_6</item> + <item name="dialog_background_color">@color/classic_light_5</item> + <item name="colorPrimaryDark">@color/classic_light_6</item> + <item name="colorControlNormal">?android:textColorPrimary</item> + <item name="colorControlActivated">?colorAccent</item> + <item name="android:textColorPrimary">@color/classic_light_0</item> + <item name="android:textColorSecondary">@color/classic_light_1</item> + <item name="android:textColorTertiary">@color/classic_light_1</item> + <item name="android:textColor">?android:textColorPrimary</item> + <item name="android:textColorHint">@color/gray27</item> + <item name="android:windowBackground">?colorPrimary</item> + <item name="android:navigationBarColor">@color/classic_light_navigation_bar</item> + <item name="colorCellBackground">@color/classic_light_6</item> + <item name="colorSettingsBackground">@color/classic_light_5</item> + <item name="colorDividerBackground">@color/classic_light_3</item> + <item name="android:colorControlHighlight">@color/classic_light_3</item> + <item name="colorControlHighlight">@color/classic_light_3</item> + <item name="bottomSheetDialogTheme">@style/Classic.Light.BottomSheet</item> + <item name="android:actionMenuTextColor">?android:textColorPrimary</item> + <item name="popupTheme">?actionBarPopupTheme</item> + <item name="actionBarPopupTheme">@style/Light.Popup</item> + <item name="actionBarWidgetTheme">@null</item> + <item name="actionBarTheme">@style/ThemeOverlay.AppCompat.ActionBar</item> + <item name="actionBarStyle">@style/Widget.Session.ActionBar</item> + <item name="accentColor">?colorAccent</item> + <item name="prominentButtonColor">?android:textColorPrimary</item> + <item name="elementBorderColor">@color/classic_light_3</item> + + <!-- Light mode --> + <item name="theme_type">light</item> + <item name="android:colorBackgroundFloating">?colorPrimary</item> + <item name="android:windowLightStatusBar">true</item> + <item name="android:windowLightNavigationBar" tools:targetApi="O_MR1">true</item> + <item name="isLightTheme">true</item> + <item name="android:isLightTheme" tools:targetApi="Q">true</item> + <item name="android:statusBarColor">?colorPrimary</item> + + <!-- Home screen --> + <item name="searchBackgroundColor">@color/classic_light_4</item> + <item name="searchIconColor">@color/classic_light_0</item> + <item name="searchHintColor">@color/classic_light_1</item> + <item name="searchTextColor">?android:textColorPrimary</item> + <item name="searchHighlightTint">?colorAccent</item> + <item name="home_gradient_start">#00000000</item> + <item name="home_gradient_end">@color/classic_light_5</item> + <item name="conversation_pinned_background_color">?colorCellBackground</item> + <item name="conversation_unread_background_color">@color/classic_light_6</item> + <item name="conversation_pinned_icon_color">?android:textColorSecondary</item> + <item name="unreadIndicatorBackgroundColor">@color/classic_light_3</item> + <item name="unreadIndicatorTextColor">@color/classic_light_0</item> + <!-- New conversation button --> + <item name="conversation_color_non_main">@color/classic_light_4</item> + <item name="conversation_tint_non_main">@color/classic_light_4</item> + <item name="conversation_shadow_non_main">@color/transparent_black_30</item> + <item name="conversation_shadow_main">@color/transparent_black_30</item> + <item name="conversation_menu_background_color">@color/classic_light_6</item> + <item name="conversation_menu_cell_color">@color/classic_light_5</item> + <item name="conversation_menu_border_color">@color/classic_light_3</item> + <item name="conversationMenuSearchBackgroundColor">@color/classic_light_6</item> + + <!-- Conversation --> + <item name="message_received_background_color">@color/classic_light_3</item> + <item name="message_received_text_color">@color/classic_light_0</item> + <item name="message_sent_background_color">?colorAccent</item> + <item name="message_sent_text_color">@color/classic_light_0</item> + <item name="message_status_color">@color/classic_light_1</item> + <item name="input_bar_background">@color/classic_light_6</item> + <item name="input_bar_text_hint">@color/classic_light_1</item> + <item name="input_bar_text_user">@color/classic_light_0</item> + <item name="input_bar_border">@color/classic_light_2</item> + <item name="input_bar_button_background">@color/classic_light_4</item> + <item name="input_bar_button_text_color">@color/classic_light_0</item> + <item name="input_bar_button_background_opaque">@color/classic_light_4</item> + <item name="input_bar_button_background_opaque_border">@color/classic_light_2</item> + <item name="input_bar_lock_view_background">@color/classic_light_4</item> + <item name="input_bar_lock_view_border">@color/classic_light_2</item> + <item name="mention_candidates_view_background">?colorCellBackground</item> + <item name="scroll_to_bottom_button_background">@color/classic_light_4</item> + <item name="scroll_to_bottom_button_border">@color/classic_light_2</item> + <item name="conversation_unread_count_indicator_background">@color/classic_dark_4</item> + <item name="message_selected">@color/classic_light_4</item> + + </style> + + <style name="Ocean.Dark"> + <!-- Main styles --> + <item name="sessionLogoTint">@color/ocean_dark_7</item> + <item name="colorPrimary">@color/ocean_dark_2</item> + <item name="colorPrimaryDark">@color/ocean_dark_2</item> + <item name="colorControlNormal">@color/ocean_dark_7</item> + <item name="colorControlActivated">?colorAccent</item> + <item name="android:textColorPrimary">@color/ocean_dark_7</item> + <item name="android:textColorSecondary">@color/ocean_dark_5</item> + <item name="android:textColorTertiary">@color/ocean_dark_5</item> + <item name="android:textColor">?android:textColorPrimary</item> + <item name="android:textColorHint">@color/ocean_dark_5</item> + <item name="android:windowBackground">?colorPrimary</item> + <item name="android:navigationBarColor">@color/navigation_bar</item> + <item name="default_background_end">?colorPrimary</item> + <item name="default_background_start">?colorPrimaryDark</item> + <item name="colorCellBackground">@color/ocean_dark_3</item> + <item name="colorSettingsBackground">@color/ocean_dark_1</item> + <item name="colorDividerBackground">@color/ocean_dark_4</item> + <item name="android:colorControlHighlight">@color/ocean_dark_4</item> + <item name="colorControlHighlight">@color/ocean_dark_4</item> + <item name="bottomSheetDialogTheme">@style/Ocean.Dark.BottomSheet</item> + <item name="popupTheme">?actionBarPopupTheme</item> + <item name="actionMenuTextColor">?android:textColorPrimary</item> + <item name="actionBarPopupTheme">@style/Dark.Popup</item> + <item name="actionBarWidgetTheme">@null</item> + <item name="actionBarTheme">@style/ThemeOverlay.AppCompat.Dark.ActionBar</item> + <item name="actionBarStyle">@style/Widget.Session.ActionBar</item> + <item name="accentColor">?colorAccent</item> + <item name="prominentButtonColor">?colorAccent</item> + <item name="elementBorderColor">@color/ocean_dark_4</item> + + <item name="isLightTheme">false</item> + + <!-- Home screen --> + <item name="searchBackgroundColor">@color/ocean_dark_3</item> + <item name="searchIconColor">@color/ocean_dark_7</item> + <item name="searchHintColor">@color/ocean_dark_5</item> + <item name="searchTextColor">?android:textColorPrimary</item> + <item name="searchHighlightTint">?colorAccent</item> + <item name="home_gradient_start">#00000000</item> + <item name="home_gradient_end">@color/ocean_dark_3</item> + <item name="conversation_pinned_background_color">?colorCellBackground</item> + <item name="conversation_unread_background_color">@color/ocean_dark_4</item> + <item name="conversation_pinned_icon_color">?android:textColorSecondary</item> + <item name="unreadIndicatorBackgroundColor">?colorAccent</item> + <item name="unreadIndicatorTextColor">@color/ocean_dark_0</item> + <item name="conversation_menu_background_color">@color/ocean_dark_3</item> + <item name="conversation_menu_cell_color">@color/ocean_dark_2</item> + <item name="conversation_menu_border_color">@color/ocean_dark_4</item> + <item name="conversationMenuSearchBackgroundColor">@color/ocean_dark_2</item> + + + + <!-- New conversation button --> + <item name="conversation_color_non_main">@color/ocean_dark_2</item> + <item name="conversation_shadow_non_main">@color/transparent_black_30</item> + <item name="conversation_shadow_main">?colorAccent</item> + + <!-- Conversation --> + <item name="message_received_background_color">@color/ocean_dark_4</item> + <item name="message_received_text_color">@color/ocean_dark_7</item> + <item name="message_sent_background_color">?colorAccent</item> + <item name="message_sent_text_color">@color/ocean_dark_0</item> + <item name="message_status_color">@color/ocean_dark_5</item> + <item name="input_bar_background">@color/ocean_dark_1</item> + <item name="input_bar_text_hint">@color/ocean_dark_5</item> + <item name="input_bar_text_user">@color/ocean_dark_7</item> + <item name="input_bar_border">@color/ocean_dark_4</item> + <item name="input_bar_button_background">@color/ocean_dark_2</item> + <item name="input_bar_button_text_color">@color/ocean_dark_7</item> + <item name="input_bar_button_background_opaque">@color/ocean_dark_4</item> + <item name="input_bar_button_background_opaque_border">@color/ocean_dark_2</item> + <item name="input_bar_lock_view_background">?colorPrimary</item> + <item name="input_bar_lock_view_border">?colorPrimary</item> + <item name="mention_candidates_view_background">@color/ocean_dark_2</item> + <item name="scroll_to_bottom_button_background">@color/ocean_dark_4</item> + <item name="scroll_to_bottom_button_border">?colorPrimary</item> + <item name="conversation_unread_count_indicator_background">@color/ocean_dark_4</item> + <item name="message_selected">@color/ocean_dark_1</item> + + </style> + + <style name="Ocean.Light"> + <!-- Main styles --> + <item name="sessionLogoTint">@color/ocean_light_1</item> + <item name="colorPrimary">@color/ocean_light_7</item> + <item name="colorPrimaryDark">@color/ocean_light_6</item> + <item name="colorControlNormal">@color/ocean_light_1</item> + <item name="colorControlActivated">?colorAccent</item> + <item name="android:textColorPrimary">@color/ocean_light_1</item> + <item name="android:textColorSecondary">@color/ocean_light_2</item> + <item name="android:textColorTertiary">@color/ocean_light_2</item> + <item name="android:textColor">?android:textColorPrimary</item> + <item name="android:textColorHint">@color/ocean_light_6</item> + <item name="android:navigationBarColor">@color/ocean_light_navigation_bar</item> + <item name="android:windowBackground">?colorPrimary</item> + <item name="default_background_end">@color/ocean_light_7</item> + <item name="default_background_start">@color/ocean_light_6</item> + <item name="colorCellBackground">@color/ocean_light_5</item> + <item name="colorSettingsBackground">@color/ocean_light_6</item> + <item name="colorDividerBackground">@color/ocean_light_3</item> + <item name="android:colorControlHighlight">@color/ocean_light_4</item> + <item name="colorControlHighlight">@color/ocean_light_4</item> + <item name="bottomSheetDialogTheme">@style/Ocean.Light.BottomSheet</item> + <item name="actionBarPopupTheme">@style/Light.Popup</item> + <item name="popupTheme">?actionBarPopupTheme</item> + <item name="actionMenuTextColor">?android:textColorPrimary</item> + <item name="actionBarWidgetTheme">@null</item> + <item name="actionBarTheme">@style/ThemeOverlay.AppCompat.ActionBar</item> + <item name="actionBarStyle">@style/Widget.Session.ActionBar</item> + <item name="accentColor">?colorAccent</item> + <item name="prominentButtonColor">?android:textColorPrimary</item> + <item name="elementBorderColor">@color/ocean_light_3</item> + + <!-- Light mode --> + <item name="theme_type">light</item> + <item name="android:colorBackgroundFloating">?colorPrimary</item> + <item name="android:windowLightStatusBar">true</item> + <item name="android:windowLightNavigationBar" tools:targetApi="O_MR1">true</item> + <item name="isLightTheme">true</item> + <item name="android:isLightTheme" tools:targetApi="Q">true</item> + <item name="android:statusBarColor">?colorPrimary</item> + + + <item name="searchBackgroundColor">@color/ocean_light_5</item> + <item name="searchIconColor">@color/ocean_light_1</item> + <item name="searchHintColor">@color/ocean_light_2</item> + <item name="searchTextColor">@color/ocean_light_1</item> + <item name="searchHighlightTint">?colorAccent</item> + + <item name="home_gradient_start">#00000000</item> + <item name="home_gradient_end">@color/ocean_light_7</item> + <item name="conversation_shadow_non_main">@color/black</item> + <item name="conversation_shadow_main">@color/black</item> + <item name="conversation_menu_background_color">@color/ocean_light_7</item> + <item name="conversation_menu_cell_color">@color/ocean_light_6</item> + <item name="conversation_menu_border_color">@color/ocean_light_3</item> + <item name="conversationMenuSearchBackgroundColor">@color/ocean_light_7</item> + + <item name="unreadIndicatorBackgroundColor">?colorAccent</item> + <item name="unreadIndicatorTextColor">@color/ocean_light_1</item> + + <!-- Conversation --> + <item name="message_received_background_color">@color/ocean_light_4</item> + <item name="message_received_text_color">@color/ocean_light_1</item> + <item name="message_sent_background_color">?colorAccent</item> + <item name="message_sent_text_color">@color/ocean_light_1</item> + <item name="message_status_color">@color/ocean_light_2</item> + <item name="input_bar_background">@color/ocean_light_7</item> + <item name="input_bar_text_hint">@color/ocean_light_2</item> + <item name="input_bar_text_user">@color/ocean_light_1</item> + <item name="input_bar_border">@color/ocean_light_3</item> + <item name="input_bar_button_background">@color/ocean_light_5</item> + <item name="input_bar_button_background_opaque">@color/ocean_light_5</item> + <item name="input_bar_button_text_color">@color/ocean_light_1</item> + <item name="input_bar_button_background_opaque_border">@color/ocean_light_1</item> + <item name="input_bar_lock_view_background">@color/ocean_light_5</item> + <item name="input_bar_lock_view_border">@color/ocean_light_1</item> + <item name="mention_candidates_view_background">?colorCellBackground</item> + <item name="scroll_to_bottom_button_background">?input_bar_button_background_opaque</item> + <item name="scroll_to_bottom_button_border">?input_bar_button_background_opaque_border</item> + <item name="conversation_unread_count_indicator_background">?colorAccent</item> + <item name="conversation_pinned_background_color">?colorCellBackground</item> + <item name="conversation_unread_background_color">@color/ocean_light_6</item> + <item name="conversation_pinned_icon_color">?android:textColorSecondary</item> + <item name="message_selected">@color/ocean_light_5</item> + </style> + + <!-- For testing / XML rendering --> + <style name="Theme.Session.DayNight.NoActionBar.Test" parent="Base.Theme.Session"> + <item name="dividerVertical">?android:textColorTertiary</item> + <item name="dividerHorizontal">?dividerVertical</item> + <item name="colorAccent">@color/classic_accent</item> + <item name="sessionLogoTint">@color/classic_dark_6</item> <item name="colorPrimary">@color/classic_dark_0</item> <item name="colorPrimaryDark">@color/classic_dark_0</item> @@ -334,7 +672,6 @@ <item name="colorCellBackground">@color/classic_dark_1</item> <item name="colorSettingsBackground">@color/classic_dark_1</item> <item name="colorDividerBackground">@color/classic_dark_3</item> - <item name="colorCellRipple">@color/classic_dark_3</item> <item name="actionBarPopupTheme">@style/Dark.Popup</item> <item name="actionBarWidgetTheme">@null</item> <item name="actionBarTheme">@style/ThemeOverlay.AppCompat.Dark.ActionBar</item> @@ -352,10 +689,10 @@ <item name="home_gradient_end">@color/classic_dark_1</item> <item name="conversation_pinned_background_color">?colorCellBackground</item> <item name="conversation_unread_background_color">@color/classic_dark_2</item> - <item name="conversation_pinned_icon_color">@color/classic_dark_4</item> + <item name="conversation_pinned_icon_color">?android:textColorSecondary</item> <item name="unreadIndicatorBackgroundColor">@color/classic_dark_3</item> - <item name="unreadIndicatorTextColor">@color/classic_dark_0</item> - + <item name="unreadIndicatorTextColor">@color/classic_dark_6</item> + <!-- New conversation button --> <item name="conversation_color_non_main">@color/classic_dark_2</item> <item name="conversation_shadow_non_main">@color/transparent_black_30</item> @@ -366,10 +703,11 @@ <item name="conversationMenuSearchBackgroundColor">@color/classic_dark_0</item> <!-- Conversation --> - <item name="message_received_background_color">@color/classic_dark_2</item> + <item name="message_received_background_color">@color/classic_dark_3</item> <item name="message_received_text_color">@color/classic_dark_6</item> <item name="message_sent_background_color">?colorAccent</item> <item name="message_sent_text_color">@color/classic_dark_0</item> + <item name="message_status_color">@color/classic_dark_5</item> <item name="input_bar_background">@color/classic_dark_1</item> <item name="input_bar_text_hint">@color/classic_dark_5</item> <item name="input_bar_text_user">@color/classic_dark_6</item> @@ -381,262 +719,11 @@ <item name="input_bar_lock_view_background">@color/classic_dark_2</item> <item name="input_bar_lock_view_border">@color/classic_dark_3</item> <item name="mention_candidates_view_background">@color/classic_dark_2</item> - <item name="mention_candidates_view_background_ripple">@color/classic_dark_3</item> <item name="scroll_to_bottom_button_background">@color/classic_dark_1</item> <item name="scroll_to_bottom_button_border">@color/classic_dark_3</item> <item name="conversation_unread_count_indicator_background">@color/classic_dark_4</item> - <item name="message_selected">@color/classic_dark_1</item> + <item name="message_selected">@color/classic_dark_2</item> </style> - <style name="Classic.Light"> - <!-- Main styles --> - <item name="sessionLogoTint">@color/classic_light_0</item> - <item name="colorPrimary">@color/classic_light_6</item> - <item name="dialog_background_color">@color/classic_light_5</item> - <item name="colorPrimaryDark">@color/classic_light_6</item> - <item name="colorControlNormal">?android:textColorPrimary</item> - <item name="colorControlActivated">?colorAccent</item> - <item name="android:colorControlHighlight">?colorAccent</item> - <item name="android:textColorPrimary">@color/classic_light_0</item> - <item name="android:textColorSecondary">@color/classic_light_1</item> - <item name="android:textColorTertiary">@color/classic_light_1</item> - <item name="android:textColor">?android:textColorPrimary</item> - <item name="android:textColorHint">@color/gray27</item> - <item name="android:windowBackground">?colorPrimary</item> - <item name="android:navigationBarColor">?colorPrimary</item> - <item name="colorCellBackground">@color/classic_light_6</item> - <item name="colorSettingsBackground">@color/classic_light_5</item> - <item name="colorDividerBackground">@color/classic_light_3</item> - <item name="colorCellRipple">@color/classic_light_3</item> - <item name="bottomSheetDialogTheme">@style/Classic.Light.BottomSheet</item> - <item name="android:actionMenuTextColor">?android:textColorPrimary</item> - <item name="popupTheme">?actionBarPopupTheme</item> - <item name="actionBarPopupTheme">@style/Light.Popup</item> - <item name="actionBarWidgetTheme">@null</item> - <item name="actionBarTheme">@style/ThemeOverlay.AppCompat.ActionBar</item> - <item name="actionBarStyle">@style/Widget.Session.ActionBar</item> - <item name="prominentButtonColor">?android:textColorPrimary</item> - <item name="elementBorderColor">@color/classic_light_3</item> - - <!-- Light mode --> - <item name="theme_type">light</item> - <item name="android:colorBackgroundFloating">?colorPrimary</item> - <item name="android:windowLightStatusBar">true</item> - <item name="android:windowLightNavigationBar" tools:targetApi="O_MR1">true</item> - <item name="android:isLightTheme" tools:targetApi="Q">true</item> - <item name="android:statusBarColor">?colorPrimary</item> - - <!-- Home screen --> - <item name="searchBackgroundColor">@color/classic_light_4</item> - <item name="searchIconColor">@color/classic_light_0</item> - <item name="searchHintColor">@color/classic_light_1</item> - <item name="searchTextColor">?android:textColorPrimary</item> - <item name="searchHighlightTint">?colorAccent</item> - <item name="home_gradient_start">#00000000</item> - <item name="home_gradient_end">@color/classic_light_5</item> - <item name="conversation_pinned_background_color">?colorCellBackground</item> - <item name="conversation_unread_background_color">@color/classic_light_6</item> - <item name="conversation_pinned_icon_color">@color/classic_light_2</item> - <item name="unreadIndicatorBackgroundColor">@color/classic_light_3</item> - <item name="unreadIndicatorTextColor">@color/classic_light_0</item> - <!-- New conversation button --> - <item name="conversation_color_non_main">@color/classic_light_4</item> - <item name="conversation_tint_non_main">@color/classic_light_4</item> - <item name="conversation_shadow_non_main">@color/transparent_black_30</item> - <item name="conversation_shadow_main">@color/transparent_black_30</item> - <item name="conversation_menu_background_color">@color/classic_light_6</item> - <item name="conversation_menu_cell_color">@color/classic_light_5</item> - <item name="conversation_menu_border_color">@color/classic_light_3</item> - <item name="conversationMenuSearchBackgroundColor">@color/classic_light_6</item> - - <!-- Conversation --> - <item name="message_received_background_color">@color/classic_light_4</item> - <item name="message_received_text_color">@color/classic_light_0</item> - <item name="message_sent_background_color">?colorAccent</item> - <item name="message_sent_text_color">@color/classic_light_0</item> - <item name="input_bar_background">@color/classic_light_6</item> - <item name="input_bar_text_hint">@color/classic_light_1</item> - <item name="input_bar_text_user">@color/classic_light_0</item> - <item name="input_bar_border">@color/classic_light_2</item> - <item name="input_bar_button_background">@color/classic_light_4</item> - <item name="input_bar_button_text_color">@color/classic_light_0</item> - <item name="input_bar_button_background_opaque">@color/classic_light_4</item> - <item name="input_bar_button_background_opaque_border">@color/classic_light_2</item> - <item name="input_bar_lock_view_background">@color/classic_light_4</item> - <item name="input_bar_lock_view_border">@color/classic_light_2</item> - <item name="mention_candidates_view_background">?colorCellBackground</item> - <item name="mention_candidates_view_background_ripple">?colorCellRipple</item> - <item name="scroll_to_bottom_button_background">@color/classic_light_4</item> - <item name="scroll_to_bottom_button_border">@color/classic_light_2</item> - <item name="conversation_unread_count_indicator_background">@color/classic_dark_4</item> - <item name="message_selected">@color/classic_light_5</item> - - </style> - - <style name="Ocean.Dark"> - <!-- Main styles --> - <item name="sessionLogoTint">@color/ocean_dark_6</item> - <item name="colorPrimary">@color/ocean_dark_2</item> - <item name="colorPrimaryDark">@color/ocean_dark_2</item> - <item name="colorControlNormal">@color/ocean_dark_6</item> - <item name="colorControlActivated">?colorAccent</item> - <item name="android:colorControlHighlight">?colorAccent</item> - <item name="android:textColorPrimary">@color/ocean_dark_6</item> - <item name="android:textColorSecondary">@color/ocean_dark_5</item> - <item name="android:textColorTertiary">@color/ocean_dark_5</item> - <item name="android:textColor">?android:textColorPrimary</item> - <item name="android:textColorHint">@color/ocean_dark_5</item> - <item name="android:windowBackground">?colorPrimary</item> - <item name="android:colorBackground">@color/default_background_start</item> - <item name="android:navigationBarColor">@color/compose_view_background</item> - <item name="default_background_end">?colorPrimary</item> - <item name="default_background_start">?colorPrimaryDark</item> - <item name="colorCellBackground">@color/ocean_dark_3</item> - <item name="colorSettingsBackground">@color/ocean_dark_1</item> - <item name="colorDividerBackground">@color/ocean_dark_4</item> - <item name="colorCellRipple">@color/ocean_dark_4</item> - <item name="bottomSheetDialogTheme">@style/Ocean.Dark.BottomSheet</item> - <item name="popupTheme">?actionBarPopupTheme</item> - <item name="actionMenuTextColor">?android:textColorPrimary</item> - <item name="actionBarPopupTheme">@style/Dark.Popup</item> - <item name="actionBarWidgetTheme">@null</item> - <item name="actionBarTheme">@style/ThemeOverlay.AppCompat.Dark.ActionBar</item> - <item name="actionBarStyle">@style/Widget.Session.ActionBar</item> - <item name="prominentButtonColor">?colorAccent</item> - <item name="elementBorderColor">@color/ocean_dark_4</item> - - <!-- Home screen --> - <item name="searchBackgroundColor">@color/ocean_dark_3</item> - <item name="searchIconColor">@color/ocean_dark_6</item> - <item name="searchHintColor">@color/ocean_dark_5</item> - <item name="searchTextColor">?android:textColorPrimary</item> - <item name="searchHighlightTint">?colorAccent</item> - <item name="home_gradient_start">#00000000</item> - <item name="home_gradient_end">@color/ocean_dark_3</item> - <item name="conversation_pinned_background_color">?colorCellBackground</item> - <item name="conversation_unread_background_color">@color/ocean_dark_4</item> - <item name="conversation_pinned_icon_color">?colorAccent</item> - <item name="unreadIndicatorBackgroundColor">?colorAccent</item> - <item name="unreadIndicatorTextColor">@color/ocean_dark_0</item> - <item name="conversation_menu_background_color">@color/ocean_dark_3</item> - <item name="conversation_menu_cell_color">@color/ocean_dark_2</item> - <item name="conversation_menu_border_color">@color/ocean_dark_4</item> - <item name="conversationMenuSearchBackgroundColor">@color/ocean_dark_2</item> - - - - <!-- New conversation button --> - <item name="conversation_color_non_main">@color/ocean_dark_2</item> - <item name="conversation_shadow_non_main">@color/transparent_black_30</item> - <item name="conversation_shadow_main">?colorAccent</item> - - <!-- Conversation --> - <item name="message_received_background_color">@color/ocean_dark_4</item> - <item name="message_received_text_color">@color/ocean_dark_6</item> - <item name="message_sent_background_color">?colorAccent</item> - <item name="message_sent_text_color">@color/ocean_dark_0</item> - <item name="input_bar_background">@color/ocean_dark_1</item> - <item name="input_bar_text_hint">@color/ocean_dark_5</item> - <item name="input_bar_text_user">@color/ocean_dark_6</item> - <item name="input_bar_border">@color/ocean_dark_4</item> - <item name="input_bar_button_background">@color/ocean_dark_2</item> - <item name="input_bar_button_text_color">@color/ocean_dark_6</item> - <item name="input_bar_button_background_opaque">@color/ocean_dark_4</item> - <item name="input_bar_button_background_opaque_border">@color/ocean_dark_2</item> - <item name="input_bar_lock_view_background">?colorPrimary</item> - <item name="input_bar_lock_view_border">?colorPrimary</item> - <item name="mention_candidates_view_background">@color/ocean_dark_2</item> - <item name="mention_candidates_view_background_ripple">@color/ocean_dark_3</item> - <item name="scroll_to_bottom_button_background">@color/ocean_dark_4</item> - <item name="scroll_to_bottom_button_border">?colorPrimary</item> - <item name="conversation_unread_count_indicator_background">@color/ocean_dark_4</item> - <item name="message_selected">@color/ocean_dark_1</item> - - </style> - - <style name="Ocean.Light"> - <!-- Main styles --> - <item name="sessionLogoTint">@color/ocean_light_0</item> - <item name="colorPrimary">@color/ocean_light_6</item> - <item name="colorPrimaryDark">@color/ocean_light_5</item> - <item name="colorControlNormal">@color/ocean_light_0</item> - <item name="colorControlActivated">?colorAccent</item> - <item name="android:colorControlHighlight">?colorAccent</item> - <item name="android:textColorPrimary">@color/ocean_light_0</item> - <item name="android:textColorSecondary">@color/ocean_light_1</item> - <item name="android:textColorTertiary">@color/ocean_light_1</item> - <item name="android:textColor">?android:textColorPrimary</item> - <item name="android:textColorHint">@color/ocean_light_5</item> - <item name="android:navigationBarColor">@color/ocean_light_6</item> - <item name="android:windowBackground">?colorPrimary</item> - <item name="android:colorBackground">@color/default_background_start</item> - <item name="default_background_end">@color/ocean_light_6</item> - <item name="default_background_start">@color/ocean_light_5</item> - <item name="colorCellBackground">@color/ocean_light_4</item> - <item name="colorSettingsBackground">@color/ocean_light_5</item> - <item name="colorDividerBackground">@color/ocean_light_2</item> - <item name="colorCellRipple">@color/ocean_light_3</item> - <item name="bottomSheetDialogTheme">@style/Ocean.Light.BottomSheet</item> - <item name="actionBarPopupTheme">@style/Light.Popup</item> - <item name="popupTheme">?actionBarPopupTheme</item> - <item name="actionMenuTextColor">?android:textColorPrimary</item> - <item name="actionBarWidgetTheme">@null</item> - <item name="actionBarTheme">@style/ThemeOverlay.AppCompat.ActionBar</item> - <item name="actionBarStyle">@style/Widget.Session.ActionBar</item> - <item name="prominentButtonColor">?android:textColorPrimary</item> - <item name="elementBorderColor">@color/ocean_light_2</item> - - <!-- Light mode --> - <item name="theme_type">light</item> - <item name="android:colorBackgroundFloating">?colorPrimary</item> - <item name="android:windowLightStatusBar">true</item> - <item name="android:windowLightNavigationBar" tools:targetApi="O_MR1">true</item> - <item name="android:isLightTheme" tools:targetApi="Q">true</item> - <item name="android:statusBarColor">?colorPrimary</item> - - - <item name="searchBackgroundColor">@color/ocean_light_4</item> - <item name="searchIconColor">@color/ocean_light_0</item> - <item name="searchHintColor">@color/ocean_light_1</item> - <item name="searchTextColor">@color/ocean_light_0</item> - <item name="searchHighlightTint">?colorAccent</item> - - <item name="home_gradient_start">#00000000</item> - <item name="home_gradient_end">@color/ocean_light_6</item> - <item name="conversation_shadow_non_main">@color/black</item> - <item name="conversation_shadow_main">@color/black</item> - <item name="conversation_menu_background_color">@color/ocean_light_6</item> - <item name="conversation_menu_cell_color">@color/ocean_light_5</item> - <item name="conversation_menu_border_color">@color/ocean_light_2</item> - <item name="conversationMenuSearchBackgroundColor">@color/ocean_light_6</item> - - <item name="unreadIndicatorBackgroundColor">?colorAccent</item> - <item name="unreadIndicatorTextColor">@color/ocean_light_0</item> - - <!-- Conversation --> - <item name="message_received_background_color">@color/ocean_light_3</item> - <item name="message_received_text_color">@color/ocean_light_0</item> - <item name="message_sent_background_color">?colorAccent</item> - <item name="message_sent_text_color">@color/ocean_light_0</item> - <item name="input_bar_background">@color/ocean_light_6</item> - <item name="input_bar_text_hint">@color/ocean_light_1</item> - <item name="input_bar_text_user">@color/ocean_light_0</item> - <item name="input_bar_border">@color/ocean_light_2</item> - <item name="input_bar_button_background">@color/ocean_light_4</item> - <item name="input_bar_button_background_opaque">@color/ocean_light_4</item> - <item name="input_bar_button_text_color">@color/ocean_light_0</item> - <item name="input_bar_button_background_opaque_border">@color/ocean_light_0</item> - <item name="input_bar_lock_view_background">@color/ocean_light_4</item> - <item name="input_bar_lock_view_border">@color/ocean_light_0</item> - <item name="mention_candidates_view_background">?colorCellBackground</item> - <item name="mention_candidates_view_background_ripple">?colorCellRipple</item> - <item name="scroll_to_bottom_button_background">?input_bar_button_background_opaque</item> - <item name="scroll_to_bottom_button_border">?input_bar_button_background_opaque_border</item> - <item name="conversation_unread_count_indicator_background">?colorAccent</item> - <item name="conversation_pinned_background_color">?colorCellBackground</item> - <item name="conversation_unread_background_color">@color/ocean_light_5</item> - <item name="conversation_pinned_icon_color">?android:textColorPrimary</item> - <item name="message_selected">@color/ocean_light_5</item> - </style> </resources> diff --git a/app/src/main/res/xml/network_security_configuration.xml b/app/src/main/res/xml/network_security_configuration.xml index e0a3502bc1..f3a7419b55 100644 --- a/app/src/main/res/xml/network_security_configuration.xml +++ b/app/src/main/res/xml/network_security_configuration.xml @@ -4,21 +4,21 @@ <domain includeSubdomains="true">127.0.0.1</domain> </domain-config> <domain-config cleartextTrafficPermitted="false"> - <domain includeSubdomains="false">public.loki.foundation</domain> + <domain includeSubdomains="false">seed1.getsession.org</domain> <trust-anchors> - <certificates src="@raw/lf_session_cert"/> + <certificates src="@raw/seed1"/> </trust-anchors> </domain-config> <domain-config cleartextTrafficPermitted="false"> - <domain includeSubdomains="false">storage.seed1.loki.network</domain> + <domain includeSubdomains="false">seed2.getsession.org</domain> <trust-anchors> - <certificates src="@raw/seed1cert"/> + <certificates src="@raw/seed2"/> </trust-anchors> </domain-config> <domain-config cleartextTrafficPermitted="false"> - <domain includeSubdomains="false">storage.seed3.loki.network</domain> + <domain includeSubdomains="false">seed3.getsession.org</domain> <trust-anchors> - <certificates src="@raw/seed3cert"/> + <certificates src="@raw/seed3"/> </trust-anchors> </domain-config> </network-security-config> \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_app_protection.xml b/app/src/main/res/xml/preferences_app_protection.xml index 0a7a15f70e..425edaf9ed 100644 --- a/app/src/main/res/xml/preferences_app_protection.xml +++ b/app/src/main/res/xml/preferences_app_protection.xml @@ -12,15 +12,20 @@ android:title="@string/preferences_app_protection__screen_lock" android:summary="@string/preferences_app_protection__lock_signal_access_with_android_screen_lock_or_fingerprint" /> - <!-- TODO: check figure out what is needed for this --> <org.thoughtcrime.securesms.components.SwitchPreferenceCompat - android:defaultValue="true" + android:defaultValue="@bool/screen_security_default" android:key="pref_screen_security" android:title="@string/preferences__screen_security" android:summary="@string/preferences__disable_screen_security_to_allow_screen_shots" /> </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" @@ -40,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/main/res/xml/preferences_notifications.xml b/app/src/main/res/xml/preferences_notifications.xml index 52a429cc36..03945d6957 100644 --- a/app/src/main/res/xml/preferences_notifications.xml +++ b/app/src/main/res/xml/preferences_notifications.xml @@ -10,7 +10,7 @@ android:summary="@string/preferences_notifications_strategy_category_fast_mode_summary" android:defaultValue="false" /> - <Preference android:layout="@layout/go_to_device_settings" + <Preference android:title="@string/go_to_device_notification_settings" android:key="pref_notification_priority" /> </PreferenceCategory> 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/LiveDataTestUtil.kt b/app/src/sharedTest/java/org/thoughtcrime/securesms/LiveDataTestUtil.kt index 03155a910c..a8cff6341c 100644 --- a/app/src/sharedTest/java/org/thoughtcrime/securesms/LiveDataTestUtil.kt +++ b/app/src/sharedTest/java/org/thoughtcrime/securesms/LiveDataTestUtil.kt @@ -22,7 +22,7 @@ fun <T> LiveData<T>.getOrAwaitValue( var data: T? = null val latch = CountDownLatch(1) val observer = object : Observer<T> { - override fun onChanged(o: T?) { + override fun onChanged(o: T) { data = o latch.countDown() this@getOrAwaitValue.removeObserver(this) 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 fa02721558..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,34 +1,42 @@ 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.Assert.assertTrue 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, repository, storage) @@ -36,14 +44,12 @@ class ConversationViewModelTest: BaseViewModelTest() { @Before fun setUp() { - recipient = mock(Recipient::class.java) - whenever(repository.isOxenHostedOpenGroup(anyLong())).thenReturn(true) + recipient = mock() + messageRecord = mock { record -> + whenever(record.individualRecipient).thenReturn(recipient) + } whenever(repository.maybeGetRecipientForThreadId(anyLong())).thenReturn(recipient) - } - - @Test - fun `should emit group type on init`() = runBlockingTest { - assertTrue(viewModel.uiState.first().isOxenHostedOpenGroup) + whenever(repository.recipientUpdateFlow(anyLong())).thenReturn(emptyFlow()) } @Test @@ -52,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 @@ -86,7 +93,7 @@ class ConversationViewModelTest: BaseViewModelTest() { @Test fun `should delete locally`() { - val message = mock(MessageRecord::class.java) + val message = mock<MessageRecord>() viewModel.deleteLocally(message) @@ -95,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)) @@ -108,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)) @@ -145,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")) } @@ -154,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, @@ -188,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/test/java/org/thoughtcrime/securesms/jobs/FastJobStorageTest.java b/app/src/test/java/org/thoughtcrime/securesms/jobs/FastJobStorageTest.java deleted file mode 100644 index ee1f232b54..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/jobs/FastJobStorageTest.java +++ /dev/null @@ -1,360 +0,0 @@ -package org.thoughtcrime.securesms.jobs; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import android.app.Application; - -import androidx.annotation.NonNull; - -import com.annimon.stream.Stream; - -import org.junit.Test; -import org.session.libsession.messaging.utilities.Data; -import org.thoughtcrime.securesms.database.JobDatabase; -import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer; -import org.thoughtcrime.securesms.jobmanager.persistence.ConstraintSpec; -import org.thoughtcrime.securesms.jobmanager.persistence.DependencySpec; -import org.thoughtcrime.securesms.jobmanager.persistence.FullSpec; -import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -public class FastJobStorageTest { - - private static final JsonDataSerializer serializer = new JsonDataSerializer(); - private static final String EMPTY_DATA = serializer.serialize(Data.EMPTY); - - @Test - public void init_allStoredDataAvailable() { - FastJobStorage subject = new FastJobStorage(fixedDataDatabase(DataSet1.FULL_SPECS)); - - subject.init(); - - DataSet1.assertJobsMatch(subject.getAllJobSpecs()); - DataSet1.assertConstraintsMatch(subject.getAllConstraintSpecs()); - DataSet1.assertDependenciesMatch(subject.getAllDependencySpecs()); - } - - @Test - public void insertJobs_writesToDatabase() { - JobDatabase database = noopDatabase(); - FastJobStorage subject = new FastJobStorage(database); - - subject.insertJobs(DataSet1.FULL_SPECS); - - verify(database).insertJobs(DataSet1.FULL_SPECS); - } - - @Test - public void insertJobs_dataCanBeFound() { - FastJobStorage subject = new FastJobStorage(noopDatabase()); - - subject.insertJobs(DataSet1.FULL_SPECS); - - DataSet1.assertJobsMatch(subject.getAllJobSpecs()); - DataSet1.assertConstraintsMatch(subject.getAllConstraintSpecs()); - DataSet1.assertDependenciesMatch(subject.getAllDependencySpecs()); - } - - @Test - public void insertJobs_individualJobCanBeFound() { - FastJobStorage subject = new FastJobStorage(noopDatabase()); - - subject.insertJobs(DataSet1.FULL_SPECS); - - assertEquals(DataSet1.JOB_1, subject.getJobSpec(DataSet1.JOB_1.getId())); - assertEquals(DataSet1.JOB_2, subject.getJobSpec(DataSet1.JOB_2.getId())); - } - - @Test - public void updateAllJobsToBePending_writesToDatabase() { - JobDatabase database = noopDatabase(); - FastJobStorage subject = new FastJobStorage(database); - - subject.updateAllJobsToBePending(); - - verify(database).updateAllJobsToBePending(); - } - - @Test - public void updateAllJobsToBePending_allArePending() { - FullSpec fullSpec1 = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, null, 1, 1, 1, 1, 1, 1, 1, EMPTY_DATA, true), - Collections.emptyList(), - Collections.emptyList()); - FullSpec fullSpec2 = new FullSpec(new JobSpec("2", AvatarDownloadJob.KEY, null, 1, 1, 1, 1, 1, 1, 1, EMPTY_DATA, true), - Collections.emptyList(), - Collections.emptyList()); - - JobManagerFactories.getJobFactories(mock(Application.class)); - FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2))); - - subject.init(); - subject.updateAllJobsToBePending(); - - assertFalse(subject.getJobSpec("1").isRunning()); - assertFalse(subject.getJobSpec("2").isRunning()); - } - - @Test - public void updateJobRunningState_writesToDatabase() { - JobDatabase database = noopDatabase(); - FastJobStorage subject = new FastJobStorage(database); - - subject.updateJobRunningState("1", true); - - verify(database).updateJobRunningState("1", true); - } - - @Test - public void updateJobRunningState_stateUpdated() { - FastJobStorage subject = new FastJobStorage(fixedDataDatabase(DataSet1.FULL_SPECS)); - subject.init(); - - subject.updateJobRunningState(DataSet1.JOB_1.getId(), true); - assertTrue(subject.getJobSpec(DataSet1.JOB_1.getId()).isRunning()); - - subject.updateJobRunningState(DataSet1.JOB_1.getId(), false); - assertFalse(subject.getJobSpec(DataSet1.JOB_1.getId()).isRunning()); - } - - @Test - public void updateJobAfterRetry_writesToDatabase() { - JobDatabase database = noopDatabase(); - FastJobStorage subject = new FastJobStorage(database); - - subject.updateJobAfterRetry("1", true, 1, 10); - - verify(database).updateJobAfterRetry("1", true, 1, 10); - } - - @Test - public void updateJobAfterRetry_stateUpdated() { - FullSpec fullSpec = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, null, 0, 0, 0, 3, 30000, -1, -1, EMPTY_DATA, true), - Collections.emptyList(), - Collections.emptyList()); - - JobManagerFactories.getJobFactories(mock(Application.class)); - FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Collections.singletonList(fullSpec))); - - subject.init(); - subject.updateJobAfterRetry("1", false, 1, 10); - - JobSpec job = subject.getJobSpec("1"); - - assertNotNull(job); - assertFalse(job.isRunning()); - assertEquals(1, job.getRunAttempt()); - assertEquals(10, job.getNextRunAttemptTime()); - } - - @Test - public void getPendingJobsWithNoDependenciesInCreatedOrder_noneWhenEarlierItemInQueueInRunning() { - FullSpec fullSpec1 = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, true), - Collections.emptyList(), - Collections.emptyList()); - FullSpec fullSpec2 = new FullSpec(new JobSpec("2", AvatarDownloadJob.KEY, "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), - Collections.emptyList(), - Collections.emptyList()); - - JobManagerFactories.getJobFactories(mock(Application.class)); - FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2))); - subject.init(); - - assertEquals(0, subject.getPendingJobsWithNoDependenciesInCreatedOrder(1).size()); - } - - @Test - public void getPendingJobsWithNoDependenciesInCreatedOrder_noneWhenAllJobsAreRunning() { - FullSpec fullSpec = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, true), - Collections.emptyList(), - Collections.emptyList()); - - JobManagerFactories.getJobFactories(mock(Application.class)); - FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Collections.singletonList(fullSpec))); - subject.init(); - - assertEquals(0, subject.getPendingJobsWithNoDependenciesInCreatedOrder(10).size()); - } - - @Test - public void getPendingJobsWithNoDependenciesInCreatedOrder_noneWhenNextRunTimeIsAfterCurrentTime() { - FullSpec fullSpec = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, "q", 0, 10, 0, 0, 0, -1, -1, EMPTY_DATA, false), - Collections.emptyList(), - Collections.emptyList()); - - JobManagerFactories.getJobFactories(mock(Application.class)); - FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Collections.singletonList(fullSpec))); - subject.init(); - - assertEquals(0, subject.getPendingJobsWithNoDependenciesInCreatedOrder(0).size()); - } - - @Test - public void getPendingJobsWithNoDependenciesInCreatedOrder_noneWhenDependentOnAnotherJob() { - FullSpec fullSpec1 = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, true), - Collections.emptyList(), - Collections.emptyList()); - FullSpec fullSpec2 = new FullSpec(new JobSpec("2", AvatarDownloadJob.KEY, null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), - Collections.emptyList(), - Collections.singletonList(new DependencySpec("2", "1"))); - - JobManagerFactories.getJobFactories(mock(Application.class)); - FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2))); - subject.init(); - - assertEquals(0, subject.getPendingJobsWithNoDependenciesInCreatedOrder(0).size()); - } - - @Test - public void getPendingJobsWithNoDependenciesInCreatedOrder_singleEligibleJob() { - FullSpec fullSpec = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), - Collections.emptyList(), - Collections.emptyList()); - - JobManagerFactories.getJobFactories(mock(Application.class)); - FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Collections.singletonList(fullSpec))); - subject.init(); - - assertEquals(1, subject.getPendingJobsWithNoDependenciesInCreatedOrder(10).size()); - } - - @Test - public void getPendingJobsWithNoDependenciesInCreatedOrder_multipleEligibleJobs() { - FullSpec fullSpec1 = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), - Collections.emptyList(), - Collections.emptyList()); - FullSpec fullSpec2 = new FullSpec(new JobSpec("2", AvatarDownloadJob.KEY, null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), - Collections.emptyList(), - Collections.emptyList()); - - JobManagerFactories.getJobFactories(mock(Application.class)); - FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2))); - subject.init(); - - assertEquals(2, subject.getPendingJobsWithNoDependenciesInCreatedOrder(10).size()); - } - - @Test - public void getPendingJobsWithNoDependenciesInCreatedOrder_singleEligibleJobInMixedList() { - FullSpec fullSpec1 = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, true), - Collections.emptyList(), - Collections.emptyList()); - FullSpec fullSpec2 = new FullSpec(new JobSpec("2", AvatarDownloadJob.KEY, null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), - Collections.emptyList(), - Collections.emptyList()); - - JobManagerFactories.getJobFactories(mock(Application.class)); - FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2))); - subject.init(); - - List<JobSpec> jobs = subject.getPendingJobsWithNoDependenciesInCreatedOrder(10); - - assertEquals(1, jobs.size()); - assertEquals("2", jobs.get(0).getId()); - } - - @Test - public void getPendingJobsWithNoDependenciesInCreatedOrder_firstItemInQueue() { - FullSpec fullSpec1 = new FullSpec(new JobSpec("1", RetrieveProfileAvatarJob.KEY, "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), - Collections.emptyList(), - Collections.emptyList()); - FullSpec fullSpec2 = new FullSpec(new JobSpec("2", RetrieveProfileAvatarJob.KEY, "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), - Collections.emptyList(), - Collections.emptyList()); - - JobManagerFactories.getJobFactories(mock(Application.class)); - FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2))); - subject.init(); - - List<JobSpec> jobs = subject.getPendingJobsWithNoDependenciesInCreatedOrder(10); - - assertEquals(1, jobs.size()); - assertEquals("1", jobs.get(0).getId()); - } - - @Test - public void deleteJobs_writesToDatabase() { - JobDatabase database = noopDatabase(); - FastJobStorage subject = new FastJobStorage(database); - List<String> ids = Arrays.asList("1", "2"); - - subject.deleteJobs(ids); - - verify(database).deleteJobs(ids); - } - - @Test - public void deleteJobs_deletesAllRelevantPieces() { - FastJobStorage subject = new FastJobStorage(fixedDataDatabase(DataSet1.FULL_SPECS)); - - subject.init(); - subject.deleteJobs(Collections.singletonList("id1")); - - List<JobSpec> jobs = subject.getAllJobSpecs(); - List<ConstraintSpec> constraints = subject.getAllConstraintSpecs(); - List<DependencySpec> dependencies = subject.getAllDependencySpecs(); - - assertEquals(1, jobs.size()); - assertEquals(DataSet1.JOB_2, jobs.get(0)); - assertEquals(1, constraints.size()); - assertEquals(DataSet1.CONSTRAINT_2, constraints.get(0)); - assertEquals(0, dependencies.size()); - } - - - private JobDatabase noopDatabase() { - JobDatabase database = mock(JobDatabase.class); - - when(database.getAllJobSpecs()).thenReturn(Collections.emptyList()); - when(database.getAllConstraintSpecs()).thenReturn(Collections.emptyList()); - when(database.getAllDependencySpecs()).thenReturn(Collections.emptyList()); - - return database; - } - - private JobDatabase fixedDataDatabase(List<FullSpec> fullSpecs) { - JobDatabase database = mock(JobDatabase.class); - - when(database.getAllJobSpecs()).thenReturn(Stream.of(fullSpecs).map(FullSpec::getJobSpec).toList()); - when(database.getAllConstraintSpecs()).thenReturn(Stream.of(fullSpecs).map(FullSpec::getConstraintSpecs).flatMap(Stream::of).toList()); - when(database.getAllDependencySpecs()).thenReturn(Stream.of(fullSpecs).map(FullSpec::getDependencySpecs).flatMap(Stream::of).toList()); - - return database; - } - - private static final class DataSet1 { - static final JobSpec JOB_1 = new JobSpec("id1", "f1", "q1", 1, 2, 3, 4, 5, 6, 7, EMPTY_DATA, false); - static final JobSpec JOB_2 = new JobSpec("id2", "f2", "q2", 1, 2, 3, 4, 5, 6, 7, EMPTY_DATA, false); - static final ConstraintSpec CONSTRAINT_1 = new ConstraintSpec("id1", "f1"); - static final ConstraintSpec CONSTRAINT_2 = new ConstraintSpec("id2", "f2"); - static final DependencySpec DEPENDENCY_2 = new DependencySpec("id2", "id1"); - static final FullSpec FULL_SPEC_1 = new FullSpec(JOB_1, Collections.singletonList(CONSTRAINT_1), Collections.emptyList()); - static final FullSpec FULL_SPEC_2 = new FullSpec(JOB_2, Collections.singletonList(CONSTRAINT_2), Collections.singletonList(DEPENDENCY_2)); - static final List<FullSpec> FULL_SPECS = Arrays.asList(FULL_SPEC_1, FULL_SPEC_2); - - static void assertJobsMatch(@NonNull List<JobSpec> jobs) { - assertEquals(jobs.size(), 2); - assertTrue(jobs.contains(DataSet1.JOB_1)); - assertTrue(jobs.contains(DataSet1.JOB_1)); - } - - static void assertConstraintsMatch(@NonNull List<ConstraintSpec> constraints) { - assertEquals(constraints.size(), 2); - assertTrue(constraints.contains(DataSet1.CONSTRAINT_1)); - assertTrue(constraints.contains(DataSet1.CONSTRAINT_2)); - } - - static void assertDependenciesMatch(@NonNull List<DependencySpec> dependencies) { - assertEquals(dependencies.size(), 1); - assertTrue(dependencies.contains(DataSet1.DEPENDENCY_2)); - } - } -} diff --git a/app/src/test/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModelTest.kt index 737c3bec39..8cdb36b6b3 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsViewModelTest.kt @@ -26,9 +26,9 @@ class MessageRequestsViewModelTest : BaseViewModelTest() { @Test fun `should clear all message requests`() = runBlockingTest { - viewModel.clearAllMessageRequests() + viewModel.clearAllMessageRequests(block = false) - verify(repository).clearAllMessageRequests() + verify(repository).clearAllMessageRequests(block = false) } } \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/recipients/RecipientExporterTest.java b/app/src/test/java/org/thoughtcrime/securesms/recipients/RecipientExporterTest.java index 5e3467b70c..c20ce39fb1 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/recipients/RecipientExporterTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/recipients/RecipientExporterTest.java @@ -9,7 +9,7 @@ import junit.framework.TestCase; import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.runners.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnitRunner; import org.session.libsession.utilities.Address; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.RecipientExporter; diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/OpenGroupMigrationTests.kt b/app/src/test/java/org/thoughtcrime/securesms/util/OpenGroupMigrationTests.kt deleted file mode 100644 index dcf8ca231b..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/util/OpenGroupMigrationTests.kt +++ /dev/null @@ -1,281 +0,0 @@ -package org.thoughtcrime.securesms.util - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.mockito.kotlin.KStubbing -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyNoMoreInteractions -import org.session.libsession.messaging.open_groups.OpenGroup -import org.session.libsession.messaging.open_groups.OpenGroupApi -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.recipients.Recipient -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.SmsDatabase -import org.thoughtcrime.securesms.database.ThreadDatabase -import org.thoughtcrime.securesms.database.model.ThreadRecord -import org.thoughtcrime.securesms.dependencies.DatabaseComponent -import org.thoughtcrime.securesms.groups.OpenGroupMigrator -import org.thoughtcrime.securesms.groups.OpenGroupMigrator.OpenGroupMapping -import org.thoughtcrime.securesms.groups.OpenGroupMigrator.roomStub - -class OpenGroupMigrationTests { - - companion object { - const val EXAMPLE_LEGACY_ENCODED_OPEN_GROUP = "__loki_public_chat_group__!687474703a2f2f3131362e3230332e37302e33332e6f78656e" - const val EXAMPLE_NEW_ENCODED_OPEN_GROUP = "__loki_public_chat_group__!68747470733a2f2f6f70656e2e67657473657373696f6e2e6f72672e6f78656e" - const val OXEN_STUB_HEX = "6f78656e" - - const val EXAMPLE_LEGACY_SERVER_ID = "http://116.203.70.33.oxen" - const val EXAMPLE_NEW_SERVER_ID = "https://open.getsession.org.oxen" - - const val LEGACY_THREAD_ID = 1L - const val NEW_THREAD_ID = 2L - } - - private fun legacyOpenGroupRecipient(additionalMocks: ((KStubbing<Recipient>) -> Unit) ? = null) = mock<Recipient> { - on { address } doReturn Address.fromSerialized(EXAMPLE_LEGACY_ENCODED_OPEN_GROUP) - on { isOpenGroupRecipient } doReturn true - additionalMocks?.let { it(this) } - } - - private fun newOpenGroupRecipient(additionalMocks: ((KStubbing<Recipient>) -> Unit) ? = null) = mock<Recipient> { - on { address } doReturn Address.fromSerialized(EXAMPLE_NEW_ENCODED_OPEN_GROUP) - on { isOpenGroupRecipient } doReturn true - additionalMocks?.let { it(this) } - } - - private fun legacyThreadRecord(additionalRecipientMocks: ((KStubbing<Recipient>) -> Unit) ? = null, additionalThreadMocks: ((KStubbing<ThreadRecord>) -> Unit)? = null) = mock<ThreadRecord> { - val returnedRecipient = legacyOpenGroupRecipient(additionalRecipientMocks) - on { recipient } doReturn returnedRecipient - on { threadId } doReturn LEGACY_THREAD_ID - } - - private fun newThreadRecord(additionalRecipientMocks: ((KStubbing<Recipient>) -> Unit)? = null, additionalThreadMocks: ((KStubbing<ThreadRecord>) -> Unit)? = null) = mock<ThreadRecord> { - val returnedRecipient = newOpenGroupRecipient(additionalRecipientMocks) - on { recipient } doReturn returnedRecipient - on { threadId } doReturn NEW_THREAD_ID - } - - @Test - fun `it should generate the correct room stubs for legacy groups`() { - val mockRecipient = legacyOpenGroupRecipient() - assertEquals(OXEN_STUB_HEX, mockRecipient.roomStub()) - } - - @Test - fun `it should generate the correct room stubs for new groups`() { - val mockNewRecipient = newOpenGroupRecipient() - assertEquals(OXEN_STUB_HEX, mockNewRecipient.roomStub()) - } - - @Test - fun `it should return correct mappings`() { - val legacyThread = legacyThreadRecord() - val newThread = newThreadRecord() - - val expectedMapping = listOf( - OpenGroupMapping(OXEN_STUB_HEX, LEGACY_THREAD_ID, NEW_THREAD_ID) - ) - - assertTrue(expectedMapping.containsAll(OpenGroupMigrator.getExistingMappings(listOf(legacyThread), listOf(newThread)))) - } - - @Test - fun `it should return no mappings if there are no legacy open groups`() { - val mappings = OpenGroupMigrator.getExistingMappings(listOf(), listOf()) - assertTrue(mappings.isEmpty()) - } - - @Test - fun `it should return no mappings if there are only new open groups`() { - val newThread = newThreadRecord() - val mappings = OpenGroupMigrator.getExistingMappings(emptyList(), listOf(newThread)) - assertTrue(mappings.isEmpty()) - } - - @Test - fun `it should return null new thread in mappings if there are only legacy open groups`() { - val legacyThread = legacyThreadRecord() - val mappings = OpenGroupMigrator.getExistingMappings(listOf(legacyThread), emptyList()) - val expectedMappings = listOf( - OpenGroupMapping(OXEN_STUB_HEX, LEGACY_THREAD_ID, null) - ) - assertTrue(expectedMappings.containsAll(mappings)) - } - - @Test - fun `test migration thread DB calls legacy and returns if no legacy official groups`() { - val mockedThreadDb = mock<ThreadDatabase> { - on { legacyOxenOpenGroups } doReturn emptyList() - } - val mockedDbComponent = mock<DatabaseComponent> { - on { threadDatabase() } doReturn mockedThreadDb - } - - OpenGroupMigrator.migrate(mockedDbComponent) - - verify(mockedDbComponent).threadDatabase() - verify(mockedThreadDb).legacyOxenOpenGroups - verifyNoMoreInteractions(mockedThreadDb) - } - - @Test - fun `it should migrate on thread, group and loki dbs with correct values for legacy only migration`() { - // mock threadDB - val capturedThreadId = argumentCaptor<Long>() - val capturedNewEncoded = argumentCaptor<String>() - val mockedThreadDb = mock<ThreadDatabase> { - val legacyThreadRecord = legacyThreadRecord() - on { legacyOxenOpenGroups } doReturn listOf(legacyThreadRecord) - on { httpsOxenOpenGroups } doReturn emptyList() - on { migrateEncodedGroup(capturedThreadId.capture(), capturedNewEncoded.capture()) } doAnswer {} - } - - // mock groupDB - val capturedGroupLegacyEncoded = argumentCaptor<String>() - val capturedGroupNewEncoded = argumentCaptor<String>() - val mockedGroupDb = mock<GroupDatabase> { - on { - migrateEncodedGroup( - capturedGroupLegacyEncoded.capture(), - capturedGroupNewEncoded.capture() - ) - } doAnswer {} - } - - // mock LokiAPIDB - val capturedLokiLegacyGroup = argumentCaptor<String>() - val capturedLokiNewGroup = argumentCaptor<String>() - val mockedLokiApi = mock<LokiAPIDatabase> { - on { migrateLegacyOpenGroup(capturedLokiLegacyGroup.capture(), capturedLokiNewGroup.capture()) } doAnswer {} - } - - val pubKey = OpenGroupApi.defaultServerPublicKey - val room = "oxen" - val legacyServer = OpenGroupApi.legacyDefaultServer - val newServer = OpenGroupApi.defaultServer - - val lokiThreadOpenGroup = argumentCaptor<OpenGroup>() - val mockedLokiThreadDb = mock<LokiThreadDatabase> { - on { getOpenGroupChat(eq(LEGACY_THREAD_ID)) } doReturn OpenGroup(legacyServer, room, "Oxen", 0, pubKey) - on { setOpenGroupChat(lokiThreadOpenGroup.capture(), eq(LEGACY_THREAD_ID)) } doAnswer {} - } - - val mockedDbComponent = mock<DatabaseComponent> { - on { threadDatabase() } doReturn mockedThreadDb - on { groupDatabase() } doReturn mockedGroupDb - on { lokiAPIDatabase() } doReturn mockedLokiApi - on { lokiThreadDatabase() } doReturn mockedLokiThreadDb - } - - OpenGroupMigrator.migrate(mockedDbComponent) - - // expect threadDB migration to reflect new thread values: - // thread ID = 1, encoded ID = new encoded ID - assertEquals(LEGACY_THREAD_ID, capturedThreadId.firstValue) - assertEquals(EXAMPLE_NEW_ENCODED_OPEN_GROUP, capturedNewEncoded.firstValue) - - // expect groupDB migration to reflect new thread values: - // legacy encoded ID, new encoded ID - assertEquals(EXAMPLE_LEGACY_ENCODED_OPEN_GROUP, capturedGroupLegacyEncoded.firstValue) - assertEquals(EXAMPLE_NEW_ENCODED_OPEN_GROUP, capturedGroupNewEncoded.firstValue) - - // expect Loki API DB migration to reflect new thread values: - assertEquals("${OpenGroupApi.legacyDefaultServer}.oxen", capturedLokiLegacyGroup.firstValue) - assertEquals("${OpenGroupApi.defaultServer}.oxen", capturedLokiNewGroup.firstValue) - - assertEquals(newServer, lokiThreadOpenGroup.firstValue.server) - - } - - @Test - fun `it should migrate and delete legacy thread with conflicting new and old values`() { - - // mock threadDB - val capturedThreadId = argumentCaptor<Long>() - val mockedThreadDb = mock<ThreadDatabase> { - val legacyThreadRecord = legacyThreadRecord() - val newThreadRecord = newThreadRecord() - on { legacyOxenOpenGroups } doReturn listOf(legacyThreadRecord) - on { httpsOxenOpenGroups } doReturn listOf(newThreadRecord) - on { deleteConversation(capturedThreadId.capture()) } doAnswer {} - } - - // mock groupDB - val capturedGroupLegacyEncoded = argumentCaptor<String>() - val mockedGroupDb = mock<GroupDatabase> { - on { delete(capturedGroupLegacyEncoded.capture()) } doReturn true - } - - // mock LokiAPIDB - val capturedLokiLegacyGroup = argumentCaptor<String>() - val capturedLokiNewGroup = argumentCaptor<String>() - val mockedLokiApi = mock<LokiAPIDatabase> { - on { migrateLegacyOpenGroup(capturedLokiLegacyGroup.capture(), capturedLokiNewGroup.capture()) } doAnswer {} - } - - // mock messaging dbs - val migrateMmsFromThreadId = argumentCaptor<Long>() - val migrateMmsToThreadId = argumentCaptor<Long>() - - val mockedMmsDb = mock<MmsDatabase> { - on { migrateThreadId(migrateMmsFromThreadId.capture(), migrateMmsToThreadId.capture()) } doAnswer {} - } - - val migrateSmsFromThreadId = argumentCaptor<Long>() - val migrateSmsToThreadId = argumentCaptor<Long>() - val mockedSmsDb = mock<SmsDatabase> { - on { migrateThreadId(migrateSmsFromThreadId.capture(), migrateSmsToThreadId.capture()) } doAnswer {} - } - - val lokiFromThreadId = argumentCaptor<Long>() - val lokiToThreadId = argumentCaptor<Long>() - val mockedLokiMessageDatabase = mock<LokiMessageDatabase> { - on { migrateThreadId(lokiFromThreadId.capture(), lokiToThreadId.capture()) } doAnswer {} - } - - val mockedLokiThreadDb = mock<LokiThreadDatabase> { - on { removeOpenGroupChat(eq(LEGACY_THREAD_ID)) } doAnswer {} - } - - val mockedDbComponent = mock<DatabaseComponent> { - on { threadDatabase() } doReturn mockedThreadDb - on { groupDatabase() } doReturn mockedGroupDb - on { lokiAPIDatabase() } doReturn mockedLokiApi - on { mmsDatabase() } doReturn mockedMmsDb - on { smsDatabase() } doReturn mockedSmsDb - on { lokiMessageDatabase() } doReturn mockedLokiMessageDatabase - on { lokiThreadDatabase() } doReturn mockedLokiThreadDb - } - - OpenGroupMigrator.migrate(mockedDbComponent) - - // should delete thread by thread ID - assertEquals(LEGACY_THREAD_ID, capturedThreadId.firstValue) - - // should delete group by legacy encoded ID - assertEquals(EXAMPLE_LEGACY_ENCODED_OPEN_GROUP, capturedGroupLegacyEncoded.firstValue) - - // should migrate SMS from legacy thread ID to new thread ID - assertEquals(LEGACY_THREAD_ID, migrateSmsFromThreadId.firstValue) - assertEquals(NEW_THREAD_ID, migrateSmsToThreadId.firstValue) - - // should migrate MMS from legacy thread ID to new thread ID - assertEquals(LEGACY_THREAD_ID, migrateMmsFromThreadId.firstValue) - assertEquals(NEW_THREAD_ID, migrateMmsToThreadId.firstValue) - - } - - - -} \ 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 a318cea58d..9ac76c9d07 100644 --- a/build.gradle +++ b/build.gradle @@ -2,15 +2,29 @@ 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:4.2.2' + classpath "com.android.tools.build:gradle:$gradlePluginVersion" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" - classpath "com.google.gms:google-services:4.3.10" - classpath files('libs/gradle-witness.jar') + classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion" + classpath "com.google.gms:google-services:$googleServicesVersion" + 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' } } +plugins{ + id("com.google.dagger.hilt.android") version "2.44" apply false +} + allprojects { repositories { google() @@ -39,18 +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 - androidCompileSdkVersion = 30 + androidTargetSdkVersion = 34 + androidCompileSdkVersion = 34 } } \ No newline at end of file diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 2bce14c43b..a039f0df97 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -6,5 +6,5 @@ repositories { } dependencies { - implementation 'com.android.tools.build:apksig:4.0.1' + implementation 'com.android.tools.build:apksig:4.0.2' } diff --git a/gradle.properties b/gradle.properties index d8668cfa1d..1d7bc62d40 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,16 +1,40 @@ -android.useAndroidX=true +## For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx1024m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +#Mon Jun 26 09:56:43 AEST 2023 android.enableJetifier=true -org.gradle.jvmargs=-Xmx4g -kotlinVersion=1.6.0 -coroutinesVersion=1.6.0 -kotlinxJsonVersion=1.3.0 -lifecycleVersion=2.3.1 -daggerVersion=2.40.1 -glideVersion=4.11.0 -kovenantVersion=3.3.0 +gradlePluginVersion=7.3.1 +org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" +org.gradle.unsafe.configuration-cache=true + +googleServicesVersion=4.3.12 +kotlinVersion=1.8.21 +android.useAndroidX=true +appcompatVersion=1.6.1 +coreVersion=1.8.0 +coroutinesVersion=1.6.4 curve25519Version=0.6.0 -protobufVersion=2.5.0 -okhttpVersion=3.12.1 +daggerVersion=2.46.1 +glideVersion=4.11.0 jacksonDatabindVersion=2.9.8 -mockitoKotlinVersion=4.0.0 \ No newline at end of file +junitVersion=4.13.2 +kotlinxJsonVersion=1.3.3 +kovenantVersion=3.3.0 +lifecycleVersion=2.5.1 +materialVersion=1.8.0 +mockitoKotlinVersion=4.1.0 +okhttpVersion=3.12.1 +pagingVersion=3.0.0 +preferenceVersion=1.2.0 +protobufVersion=2.5.0 +testCoreVersion=1.5.0 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ea8f956162..cd825d0848 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Dec 30 07:09:53 SAST 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME 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/.gitignore b/libsession-util/.gitignore new file mode 100644 index 0000000000..606666622e --- /dev/null +++ b/libsession-util/.gitignore @@ -0,0 +1,2 @@ +/build +/.cxx/ diff --git a/libsession-util/build.gradle b/libsession-util/build.gradle new file mode 100644 index 0000000000..f368f15eea --- /dev/null +++ b/libsession-util/build.gradle @@ -0,0 +1,47 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'network.loki.messenger.libsession_util' + compileSdkVersion androidCompileSdkVersion + + defaultConfig { + minSdkVersion androidMinimumSdkVersion + targetSdkVersion androidCompileSdkVersion + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + externalNativeBuild { + cmake { + } + } + } + + buildTypes { + release { + minifyEnabled false + } + } + externalNativeBuild { + cmake { + path "src/main/cpp/CMakeLists.txt" + version "3.22.1+" + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + testImplementation 'junit:junit:4.13.2' + implementation(project(":libsignal")) + implementation "com.google.protobuf:protobuf-java:$protobufVersion" + 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 new file mode 160000 index 0000000000..626b6628a2 --- /dev/null +++ b/libsession-util/libsession-util @@ -0,0 +1 @@ +Subproject commit 626b6628a2af8fff798042416b3b469b8bfc6ecf diff --git a/libsession-util/src/androidTest/java/network/loki/messenger/libsession_util/InstrumentedTests.kt b/libsession-util/src/androidTest/java/network/loki/messenger/libsession_util/InstrumentedTests.kt new file mode 100644 index 0000000000..952c357851 --- /dev/null +++ b/libsession-util/src/androidTest/java/network/loki/messenger/libsession_util/InstrumentedTests.kt @@ -0,0 +1,584 @@ +package network.loki.messenger.libsession_util + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import network.loki.messenger.libsession_util.util.* +import org.hamcrest.CoreMatchers.not +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.Log + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class InstrumentedTests { + + val seed = + Hex.fromStringCondensed("0123456789abcdef0123456789abcdef00000000000000000000000000000000") + + private val keyPair: KeyPair + get() { + return Sodium.ed25519KeyPair(seed) + } + + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("network.loki.messenger.libsession_util.test", appContext.packageName) + } + + @Test + fun jni_test_sodium_kp_ed_curve() { + val kp = keyPair + val curvePkBytes = Sodium.ed25519PkToCurve25519(kp.pubKey) + + val edPk = kp.pubKey + val curvePk = curvePkBytes + + assertArrayEquals(Hex.fromStringCondensed("4cb76fdc6d32278e3f83dbf608360ecc6b65727934b85d2fb86862ff98c46ab7"), edPk) + assertArrayEquals(Hex.fromStringCondensed("d2ad010eeb72d72e561d9de7bd7b6989af77dcabffa03a5111a6c859ae5c3a72"), curvePk) + assertArrayEquals(kp.secretKey.take(32).toByteArray(), seed) + } + + @Test + fun testDirtyEmptyString() { + val contacts = Contacts.newInstance(keyPair.secretKey) + val definitelyRealId = "050000000000000000000000000000000000000000000000000000000000000000" + val contact = contacts.getOrConstruct(definitelyRealId) + contacts.set(contact) + assertTrue(contacts.dirty()) + contacts.set(contact.copy(name = "test")) + assertTrue(contacts.dirty()) + val push = contacts.push() + contacts.confirmPushed(push.seqNo, "abc123") + contacts.dump() + contacts.set(contact.copy(name = "test2")) + contacts.set(contact.copy(name = "test")) + assertTrue(contacts.dirty()) + } + + @Test + fun jni_contacts() { + val contacts = Contacts.newInstance(keyPair.secretKey) + val definitelyRealId = "050000000000000000000000000000000000000000000000000000000000000000" + assertNull(contacts.get(definitelyRealId)) + + // Should be an uninitialized contact apart from ID + val c = contacts.getOrConstruct(definitelyRealId) + assertEquals(definitelyRealId, c.id) + assertTrue(c.name.isEmpty()) + assertTrue(c.nickname.isEmpty()) + assertFalse(c.approved) + assertFalse(c.approvedMe) + assertFalse(c.blocked) + assertEquals(UserPic.DEFAULT, c.profilePicture) + + assertFalse(contacts.needsPush()) + assertFalse(contacts.needsDump()) + assertEquals(0, contacts.push().seqNo) + + c.name = "Joe" + c.nickname = "Joey" + c.approved = true + c.approvedMe = true + + contacts.set(c) + + val cSaved = contacts.get(definitelyRealId)!! + assertEquals("Joe", cSaved.name) + assertEquals("Joey", cSaved.nickname) + assertTrue(cSaved.approved) + assertTrue(cSaved.approvedMe) + assertFalse(cSaved.blocked) + assertEquals(UserPic.DEFAULT, cSaved.profilePicture) + + val push1 = contacts.push() + + assertEquals(1, push1.seqNo) + contacts.confirmPushed(push1.seqNo, "fakehash1") + assertFalse(contacts.needsPush()) + assertTrue(contacts.needsDump()) + + val contacts2 = Contacts.newInstance(keyPair.secretKey, contacts.dump()) + assertFalse(contacts.needsDump()) + assertFalse(contacts2.needsPush()) + assertFalse(contacts2.needsDump()) + + val anotherId = "051111111111111111111111111111111111111111111111111111111111111111" + val c2 = contacts2.getOrConstruct(anotherId) + contacts2.set(c2) + val push2 = contacts2.push() + assertEquals(2, push2.seqNo) + contacts2.confirmPushed(push2.seqNo, "fakehash2") + assertFalse(contacts2.needsPush()) + + contacts.merge("fakehash2" to push2.config) + + + assertFalse(contacts.needsPush()) + assertEquals(push2.seqNo, contacts.push().seqNo) + + val contactList = contacts.all().toList() + assertEquals(definitelyRealId, contactList[0].id) + assertEquals(anotherId, contactList[1].id) + assertEquals("Joey", contactList[0].nickname) + assertEquals("", contactList[1].nickname) + + contacts.erase(definitelyRealId) + + val thirdId ="052222222222222222222222222222222222222222222222222222222222222222" + val third = Contact( + id = thirdId, + nickname = "Nickname 3", + approved = true, + blocked = true, + profilePicture = UserPic("http://example.com/huge.bmp", "qwertyuio01234567890123456789012".encodeToByteArray()), + expiryMode = ExpiryMode.NONE + ) + contacts2.set(third) + assertTrue(contacts.needsPush()) + assertTrue(contacts2.needsPush()) + val toPush = contacts.push() + val toPush2 = contacts2.push() + assertEquals(toPush.seqNo, toPush2.seqNo) + assertThat(toPush2.config, not(equals(toPush.config))) + + contacts.confirmPushed(toPush.seqNo, "fakehash3a") + contacts2.confirmPushed(toPush2.seqNo, "fakehash3b") + + contacts.merge("fakehash3b" to toPush2.config) + contacts2.merge("fakehash3a" to toPush.config) + + assertTrue(contacts.needsPush()) + assertTrue(contacts2.needsPush()) + + val mergePush = contacts.push() + val mergePush2 = contacts2.push() + + assertEquals(mergePush.seqNo, mergePush2.seqNo) + assertArrayEquals(mergePush.config, mergePush2.config) + + assertTrue(mergePush.obsoleteHashes.containsAll(listOf("fakehash3b", "fakehash3a"))) + assertTrue(mergePush2.obsoleteHashes.containsAll(listOf("fakehash3b", "fakehash3a"))) + + } + + @Test + fun jni_accessible() { + val userProfile = UserProfile.newInstance(keyPair.secretKey) + assertNotNull(userProfile) + userProfile.free() + } + + @Test + fun jni_user_profile_c_api() { + val edSk = keyPair.secretKey + val userProfile = UserProfile.newInstance(edSk) + + // these should be false as empty config + assertFalse(userProfile.needsPush()) + assertFalse(userProfile.needsDump()) + + // Since it's empty there shouldn't be a name + assertNull(userProfile.getName()) + + // Don't need to push yet so this is just for testing + val (_, seqNo) = userProfile.push() // disregarding encrypted + assertEquals("UserProfile", userProfile.encryptionDomain()) + assertEquals(0, seqNo) + + // This should also be unset: + assertEquals(UserPic.DEFAULT, userProfile.getPic()) + + // Now let's go set a profile name and picture: + // not sending keylen like c api so cutting off the NOTSECRET in key for testing purposes + userProfile.setName("Kallie") + val newUserPic = UserPic("http://example.org/omg-pic-123.bmp", "secret78901234567890123456789012".encodeToByteArray()) + userProfile.setPic(newUserPic) + userProfile.setNtsPriority(9) + + // Retrieve them just to make sure they set properly: + assertEquals("Kallie", userProfile.getName()) + val pic = userProfile.getPic() + assertEquals("http://example.org/omg-pic-123.bmp", pic.url) + assertEquals("secret78901234567890123456789012", pic.key.decodeToString()) + + // Since we've made changes, we should need to push new config to the swarm, *and* should need + // to dump the updated state: + assertTrue(userProfile.needsPush()) + assertTrue(userProfile.needsDump()) + val (newToPush, newSeqNo) = userProfile.push() + + val expHash0 = + Hex.fromStringCondensed("ea173b57beca8af18c3519a7bbf69c3e7a05d1c049fa9558341d8ebb48b0c965") + + val expectedPush1Decrypted = ("d" + + "1:#"+ "i1e" + + "1:&"+ "d"+ + "1:+"+ "i9e"+ + "1:n"+ "6:Kallie"+ + "1:p"+ "34:http://example.org/omg-pic-123.bmp"+ + "1:q"+ "32:secret78901234567890123456789012"+ + "e"+ + "1:<"+ "l"+ + "l"+ "i0e"+ "32:").encodeToByteArray() + expHash0 + ("de"+ "e"+ + "e"+ + "1:="+ "d"+ + "1:+" +"0:"+ + "1:n" +"0:"+ + "1:p" +"0:"+ + "1:q" +"0:"+ + "e"+ + "e").encodeToByteArray() + + assertEquals(1, newSeqNo) + // We haven't dumped, so still need to dump: + assertTrue(userProfile.needsDump()) + // We did call push but we haven't confirmed it as stored yet, so this will still return true: + assertTrue(userProfile.needsPush()) + + val dump = userProfile.dump() + // (in a real client we'd now store this to disk) + assertFalse(userProfile.needsDump()) + val expectedDump = ("d" + + "1:!"+ "i2e" + + "1:$").encodeToByteArray() + expectedPush1Decrypted.size.toString().encodeToByteArray() + + ":".encodeToByteArray() + expectedPush1Decrypted + + "1:(0:1:)le".encodeToByteArray()+ + "e".encodeToByteArray() + + assertArrayEquals(expectedDump, dump) + + userProfile.confirmPushed(newSeqNo, "fakehash1") + + val newConf = UserProfile.newInstance(edSk) + + val accepted = newConf.merge("fakehash1" to newToPush) + assertEquals(1, accepted) + + assertTrue(newConf.needsDump()) + assertFalse(newConf.needsPush()) + val _ignore = newConf.dump() + assertFalse(newConf.needsDump()) + + + userProfile.setName("Raz") + newConf.setName("Nibbler") + newConf.setPic(UserPic("http://new.example.com/pic", "qwertyuio01234567890123456789012".encodeToByteArray())) + + val conf = userProfile.push() + val conf2 = newConf.push() + + userProfile.confirmPushed(conf.seqNo, "fakehash2") + newConf.confirmPushed(conf2.seqNo, "fakehash3") + + userProfile.dump() + + assertFalse(conf.config.contentEquals(conf2.config)) + + newConf.merge("fakehash2" to conf.config) + userProfile.merge("fakehash3" to conf2.config) + + assertTrue(newConf.needsPush()) + assertTrue(userProfile.needsPush()) + + val newSeq1 = userProfile.push() + + assertEquals(3, newSeq1.seqNo) + + userProfile.confirmPushed(newSeq1.seqNo, "fakehash4") + + // assume newConf push gets rejected as it was last to write and clear previous config by hash on oxenss + newConf.merge("fakehash4" to newSeq1.config) + + val newSeqMerge = newConf.push() + + newConf.confirmPushed(newSeqMerge.seqNo, "fakehash5") + + assertEquals("Raz", newConf.getName()) + assertEquals(3, newSeqMerge.seqNo) + + // userProfile device polls and merges + userProfile.merge("fakehash5" to newSeqMerge.config) + + val userConfigMerge = userProfile.push() + + assertEquals(3, userConfigMerge.seqNo) + + assertEquals("Raz", newConf.getName()) + assertEquals("Raz", userProfile.getName()) + + userProfile.free() + newConf.free() + } + + @Test + fun merge_resolves_conflicts() { + val kp = keyPair + val a = UserProfile.newInstance(kp.secretKey) + val b = UserProfile.newInstance(kp.secretKey) + a.setName("A") + val (aPush, aSeq) = a.push() + a.confirmPushed(aSeq, "hashfroma") + b.setName("B") + // polls and sees invalid state, has to merge + b.merge("hashfroma" to aPush) + val (bPush, bSeq) = b.push() + b.confirmPushed(bSeq, "hashfromb") + assertEquals("B", b.getName()) + assertEquals(1, aSeq) + assertEquals(2, bSeq) + a.merge("hashfromb" to bPush) + assertEquals(2, a.push().seqNo) + } + + @Test + fun jni_setting_getting() { + val userProfile = UserProfile.newInstance(keyPair.secretKey) + val newName = "test" + println("Name being set via JNI call: $newName") + userProfile.setName(newName) + val nameFromNative = userProfile.getName() + assertEquals(newName, nameFromNative) + println("Name received by JNI call: $nameFromNative") + assertTrue(userProfile.dirty()) + userProfile.free() + } + + @Test + fun jni_remove_all_test() { + val convos = ConversationVolatileConfig.newInstance(keyPair.secretKey) + assertEquals(0 /* number removed */, convos.eraseAll { true /* 'erase' every item */ }) + + val definitelyRealId = "050000000000000000000000000000000000000000000000000000000000000000" + val definitelyRealConvo = Conversation.OneToOne(definitelyRealId, System.currentTimeMillis(), false) + convos.set(definitelyRealConvo) + + val anotherDefinitelyReadId = "051111111111111111111111111111111111111111111111111111111111111111" + val anotherDefinitelyRealConvo = Conversation.OneToOne(anotherDefinitelyReadId, System.currentTimeMillis(), false) + convos.set(anotherDefinitelyRealConvo) + + assertEquals(2, convos.sizeOneToOnes()) + + val numErased = convos.eraseAll { convo -> + convo is Conversation.OneToOne && convo.sessionId == definitelyRealId + } + assertEquals(1, numErased) + assertEquals(1, convos.sizeOneToOnes()) + } + + @Test + fun test_open_group_urls() { + val (base1, room1, pk1) = BaseCommunityInfo.parseFullUrl( + "https://example.com/" + + "someroom?public_key=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + )!! + + val (base2, room2, pk2) = BaseCommunityInfo.parseFullUrl( + "HTTPS://EXAMPLE.COM/" + + "someroom?public_key=0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" + )!! + + val (base3, room3, pk3) = BaseCommunityInfo.parseFullUrl( + "HTTPS://EXAMPLE.COM/r/" + + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + )!! + + val (base4, room4, pk4) = BaseCommunityInfo.parseFullUrl( + "http://example.com/r/" + + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + )!! + + val (base5, room5, pk5) = BaseCommunityInfo.parseFullUrl( + "HTTPS://EXAMPLE.com:443/r/" + + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + )!! + + val (base6, room6, pk6) = BaseCommunityInfo.parseFullUrl( + "HTTP://EXAMPLE.com:80/r/" + + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + )!! + + val (base7, room7, pk7) = BaseCommunityInfo.parseFullUrl( + "http://example.com:80/r/" + + "someroom?public_key=ASNFZ4mrze8BI0VniavN7wEjRWeJq83vASNFZ4mrze8" + )!! + val (base8, room8, pk8) = BaseCommunityInfo.parseFullUrl( + "http://example.com:80/r/" + + "someroom?public_key=yrtwk3hjixg66yjdeiuauk6p7hy1gtm8tgih55abrpnsxnpm3zzo" + )!! + + assertEquals("https://example.com", base1) + assertEquals("http://example.com", base4) + assertEquals(base1, base2) + assertEquals(base1, base3) + assertNotEquals(base1, base4) + assertEquals(base1, base5) + assertEquals(base4, base6) + assertEquals(base4, base7) + assertEquals(base4, base8) + assertEquals("someroom", room1) + assertEquals("someroom", room2) + assertEquals("someroom", room3) + assertEquals("someroom", room4) + assertEquals("someroom", room5) + assertEquals("someroom", room6) + assertEquals("someroom", room7) + assertEquals("someroom", room8) + assertEquals(Hex.toStringCondensed(pk1), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk2), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk3), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk4), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk5), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk6), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk7), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + assertEquals(Hex.toStringCondensed(pk8), "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + + } + + @Test + fun test_conversations() { + val convos = ConversationVolatileConfig.newInstance(keyPair.secretKey) + val definitelyRealId = "055000000000000000000000000000000000000000000000000000000000000000" + assertNull(convos.getOneToOne(definitelyRealId)) + assertTrue(convos.empty()) + assertEquals(0, convos.size()) + + val c = convos.getOrConstructOneToOne(definitelyRealId) + + assertEquals(definitelyRealId, c.sessionId) + assertEquals(0, c.lastRead) + + assertFalse(convos.needsPush()) + assertFalse(convos.needsDump()) + assertEquals(0, convos.push().seqNo) + + val nowMs = System.currentTimeMillis() + + c.lastRead = nowMs + + convos.set(c) + + assertNull(convos.getLegacyClosedGroup(definitelyRealId)) + assertNotNull(convos.getOneToOne(definitelyRealId)) + assertEquals(nowMs, convos.getOneToOne(definitelyRealId)?.lastRead) + + assertTrue(convos.needsPush()) + assertTrue(convos.needsDump()) + + val openGroupPubKey = Hex.fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + + val og = convos.getOrConstructCommunity("http://Example.ORG:5678", "SudokuRoom", openGroupPubKey) + val ogCommunity = og.baseCommunityInfo + + assertEquals("http://example.org:5678", ogCommunity.baseUrl) // Note: lower-case + assertEquals("sudokuroom", ogCommunity.room) // Note: lower-case + assertEquals(64, ogCommunity.pubKeyHex.length) + assertEquals("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ogCommunity.pubKeyHex) + + og.unread = true + + convos.set(og) + + val (_, seqNo) = convos.push() + + assertEquals(1, seqNo) + + convos.confirmPushed(seqNo, "fakehash1") + + assertTrue(convos.needsDump()) + assertFalse(convos.needsPush()) + + val convos2 = ConversationVolatileConfig.newInstance(keyPair.secretKey, convos.dump()) + assertFalse(convos.needsPush()) + assertFalse(convos.needsDump()) + assertEquals(1, convos.push().seqNo) + assertFalse(convos.needsDump()) + + val x1 = convos2.getOneToOne(definitelyRealId)!! + assertEquals(nowMs, x1.lastRead) + assertEquals(definitelyRealId, x1.sessionId) + assertEquals(false, x1.unread) + + val x2 = convos2.getCommunity("http://EXAMPLE.org:5678", "sudokuRoom")!! + val x2Info = x2.baseCommunityInfo + assertEquals("http://example.org:5678", x2Info.baseUrl) + assertEquals("sudokuroom", x2Info.room) + assertEquals(x2Info.pubKeyHex, Hex.toStringCondensed(openGroupPubKey)) + assertTrue(x2.unread) + + val anotherId = "051111111111111111111111111111111111111111111111111111111111111111" + val c2 = convos.getOrConstructOneToOne(anotherId) + c2.unread = true + convos2.set(c2) + + val c3 = convos.getOrConstructLegacyGroup( + "05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ) + c3.lastRead = nowMs - 50 + convos2.set(c3) + + assertTrue(convos2.needsPush()) + + val (toPush2, seqNo2) = convos2.push() + assertEquals(2, seqNo2) + + convos2.confirmPushed(seqNo2, "fakehash2") + convos.merge("fakehash2" to toPush2) + + assertFalse(convos.needsPush()) + assertEquals(seqNo2, convos.push().seqNo) + + val seen = mutableListOf<String>() + for ((ind, conv) in listOf(convos, convos2).withIndex()) { + Log.e("Test","Testing seen from convo #$ind") + seen.clear() + assertEquals(4, conv.size()) + assertEquals(2, conv.sizeOneToOnes()) + assertEquals(1, conv.sizeCommunities()) + assertEquals(1, conv.sizeLegacyClosedGroups()) + assertFalse(conv.empty()) + val allConvos = conv.all() + for (convo in allConvos) { + when (convo) { + is Conversation.OneToOne -> seen.add("1-to-1: ${convo.sessionId}") + is Conversation.Community -> seen.add("og: ${convo.baseCommunityInfo.baseUrl}/r/${convo.baseCommunityInfo.room}") + is Conversation.LegacyGroup -> seen.add("cl: ${convo.groupId}") + } + } + + assertTrue(seen.contains("1-to-1: 051111111111111111111111111111111111111111111111111111111111111111")) + assertTrue(seen.contains("1-to-1: 055000000000000000000000000000000000000000000000000000000000000000")) + assertTrue(seen.contains("og: http://example.org:5678/r/sudokuroom")) + assertTrue(seen.contains("cl: 05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")) + assertTrue(seen.size == 4) // for some reason iterative checks aren't working in test cases + } + + assertFalse(convos.needsPush()) + convos.eraseOneToOne("052000000000000000000000000000000000000000000000000000000000000000") + assertFalse(convos.needsPush()) + convos.eraseOneToOne("055000000000000000000000000000000000000000000000000000000000000000") + assertTrue(convos.needsPush()) + + assertEquals(1, convos.allOneToOnes().size) + assertEquals("051111111111111111111111111111111111111111111111111111111111111111", + convos.allOneToOnes().map(Conversation.OneToOne::sessionId).first() + ) + assertEquals(1, convos.allCommunities().size) + assertEquals("http://example.org:5678", + convos.allCommunities().map { it.baseCommunityInfo.baseUrl }.first() + ) + assertEquals(1, convos.allLegacyClosedGroups().size) + assertEquals("05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + convos.allLegacyClosedGroups().map(Conversation.LegacyGroup::groupId).first() + ) + } + +} \ No newline at end of file diff --git a/libsession-util/src/main/AndroidManifest.xml b/libsession-util/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..65483324a6 --- /dev/null +++ b/libsession-util/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="network.loki.messenger.libsession_util"> + +</manifest> \ No newline at end of file diff --git a/libsession-util/src/main/cpp/CMakeLists.txt b/libsession-util/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000000..47fee4803c --- /dev/null +++ b/libsession-util/src/main/cpp/CMakeLists.txt @@ -0,0 +1,67 @@ +# For more information about using CMake with Android Studio, read the +# documentation: https://d.android.com/studio/projects/add-native-code.html + +# Sets the minimum version of CMake required to build the native library. + +cmake_minimum_required(VERSION 3.18.1) + +# Declares and names the project. + +project("session_util") + +# Compiles in C++17 mode +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(CMAKE_BUILD_TYPE Release) + +# Creates and names a library, sets it as either STATIC +# or SHARED, and provides the relative paths to its source code. +# You can define multiple libraries, and CMake builds them for you. +# Gradle automatically packages shared libraries with your APK. + +set(STATIC_BUNDLE ON) +add_subdirectory(../../../libsession-util libsession) + +set(SOURCES + user_profile.cpp + user_groups.cpp + config_base.cpp + contacts.cpp + conversation.cpp + util.cpp) + +add_library( # Sets the name of the library. + session_util + # Sets the library as a shared library. + SHARED + # Provides a relative path to your source file(s). + ${SOURCES}) + +# Searches for a specified prebuilt library and stores the path as a +# variable. Because CMake includes system libraries in the search path by +# default, you only need to specify the name of the public NDK library +# you want to add. CMake verifies that the library exists before +# completing its build. + +find_library( # Sets the name of the path variable. + log-lib + + # Specifies the name of the NDK library that + # you want CMake to locate. + log) + +# Specifies libraries CMake should link to your target library. You +# can link multiple libraries, such as libraries you define in this +# build script, prebuilt third-party libraries, or system libraries. + +target_link_libraries( # Specifies the target library. + session_util + 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 new file mode 100644 index 0000000000..5af6483371 --- /dev/null +++ b/libsession-util/src/main/cpp/config_base.cpp @@ -0,0 +1,164 @@ +#include "config_base.h" +#include "util.h" +#include "jni_utils.h" + +extern "C" { +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_dirty(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto* configBase = ptrToConfigBase(env, thiz); + return configBase->is_dirty(); +} + +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_needsPush(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConfigBase(env, thiz); + return config->needs_push(); +} + +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_needsDump(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConfigBase(env, thiz); + return config->needs_dump(); +} + +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_push(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConfigBase(env, thiz); + auto push_tuple = config->push(); + auto to_push_str = std::get<1>(push_tuple); + auto to_delete = std::get<2>(push_tuple); + + jbyteArray returnByteArray = util::bytes_from_ustring(env, to_push_str); + jlong seqNo = std::get<0>(push_tuple); + jclass returnObjectClass = env->FindClass("network/loki/messenger/libsession_util/util/ConfigPush"); + jclass stackClass = env->FindClass("java/util/Stack"); + jmethodID methodId = env->GetMethodID(returnObjectClass, "<init>", "([BJLjava/util/List;)V"); + jmethodID stack_init = env->GetMethodID(stackClass, "<init>", "()V"); + jobject our_stack = env->NewObject(stackClass, stack_init); + jmethodID push_stack = env->GetMethodID(stackClass, "push", "(Ljava/lang/Object;)Ljava/lang/Object;"); + for (auto entry : to_delete) { + auto entry_jstring = env->NewStringUTF(entry.data()); + env->CallObjectMethod(our_stack, push_stack, entry_jstring); + } + jobject returnObject = env->NewObject(returnObjectClass, methodId, returnByteArray, seqNo, our_stack); + return returnObject; +} + +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_free(JNIEnv *env, jobject thiz) { + auto config = ptrToConfigBase(env, thiz); + delete config; +} + +JNIEXPORT jbyteArray JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_dump(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConfigBase(env, thiz); + auto dumped = config->dump(); + jbyteArray bytes = util::bytes_from_ustring(env, dumped); + return bytes; +} + +JNIEXPORT jstring JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_encryptionDomain(JNIEnv *env, + jobject thiz) { + auto conf = ptrToConfigBase(env, thiz); + return env->NewStringUTF(conf->encryption_domain()); +} + +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_confirmPushed(JNIEnv *env, jobject thiz, + jlong seq_no, + jstring new_hash_jstring) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConfigBase(env, thiz); + auto new_hash = env->GetStringUTFChars(new_hash_jstring, nullptr); + conf->confirm_pushed(seq_no, new_hash); + env->ReleaseStringUTFChars(new_hash_jstring, new_hash); +} + +#pragma clang diagnostic push +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_merge___3Lkotlin_Pair_2(JNIEnv *env, jobject thiz, + jobjectArray to_merge) { + 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 jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_merge__Lkotlin_Pair_2(JNIEnv *env, jobject thiz, + jobject to_merge) { + 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 +} +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_configNamespace(JNIEnv *env, jobject thiz) { + auto conf = ptrToConfigBase(env, thiz); + return (std::int16_t) conf->storage_namespace(); +} +extern "C" +JNIEXPORT jclass JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_00024Companion_kindFor(JNIEnv *env, + jobject thiz, + jint config_namespace) { + auto user_class = env->FindClass("network/loki/messenger/libsession_util/UserProfile"); + auto contact_class = env->FindClass("network/loki/messenger/libsession_util/Contacts"); + auto convo_volatile_class = env->FindClass("network/loki/messenger/libsession_util/ConversationVolatileConfig"); + auto group_list_class = env->FindClass("network/loki/messenger/libsession_util/UserGroupsConfig"); + switch (config_namespace) { + case (int)session::config::Namespace::UserProfile: + return user_class; + case (int)session::config::Namespace::Contacts: + return contact_class; + case (int)session::config::Namespace::ConvoInfoVolatile: + return convo_volatile_class; + case (int)session::config::Namespace::UserGroups: + return group_list_class; + default: + return nullptr; + } +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConfigBase_currentHashes(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConfigBase(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;"); + auto vec = conf->current_hashes(); + for (std::string element: vec) { + env->CallObjectMethod(our_stack, push, env->NewStringUTF(element.data())); + } + return our_stack; +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/config_base.h b/libsession-util/src/main/cpp/config_base.h new file mode 100644 index 0000000000..836fb04ef5 --- /dev/null +++ b/libsession-util/src/main/cpp/config_base.h @@ -0,0 +1,28 @@ +#ifndef SESSION_ANDROID_CONFIG_BASE_H +#define SESSION_ANDROID_CONFIG_BASE_H + +#include "session/config/base.hpp" +#include "util.h" +#include <jni.h> +#include <string> + +inline session::config::ConfigBase* ptrToConfigBase(JNIEnv *env, jobject obj) { + jclass baseClass = env->FindClass("network/loki/messenger/libsession_util/ConfigBase"); + jfieldID pointerField = env->GetFieldID(baseClass, "pointer", "J"); + return (session::config::ConfigBase*) env->GetLongField(obj, pointerField); +} + +inline std::pair<std::string, session::ustring> extractHashAndData(JNIEnv *env, jobject kotlin_pair) { + jclass pair = env->FindClass("kotlin/Pair"); + jfieldID first = env->GetFieldID(pair, "first", "Ljava/lang/Object;"); + jfieldID second = env->GetFieldID(pair, "second", "Ljava/lang/Object;"); + jstring hash_as_jstring = static_cast<jstring>(env->GetObjectField(kotlin_pair, first)); + jbyteArray data_as_jbytes = static_cast<jbyteArray>(env->GetObjectField(kotlin_pair, second)); + auto hash_as_string = env->GetStringUTFChars(hash_as_jstring, nullptr); + auto data_as_ustring = util::ustring_from_bytes(env, data_as_jbytes); + auto ret_pair = std::pair<std::string, session::ustring>{hash_as_string, data_as_ustring}; + env->ReleaseStringUTFChars(hash_as_jstring, hash_as_string); + return ret_pair; +} + +#endif \ No newline at end of file diff --git a/libsession-util/src/main/cpp/contacts.cpp b/libsession-util/src/main/cpp/contacts.cpp new file mode 100644 index 0000000000..324d0f0ea8 --- /dev/null +++ b/libsession-util/src/main/cpp/contacts.cpp @@ -0,0 +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) { + // 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) { + 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) { + 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) { + 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; + }); +} +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) { + 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)); + + 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) { + 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); + + 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; + }); +} +#pragma clang diagnostic pop +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_Contacts_all(JNIEnv *env, jobject thiz) { + 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 new file mode 100644 index 0000000000..ecb2cb3749 --- /dev/null +++ b/libsession-util/src/main/cpp/contacts.h @@ -0,0 +1,108 @@ +#ifndef SESSION_ANDROID_CONTACTS_H +#define SESSION_ANDROID_CONTACTS_H + +#include <jni.h> +#include "session/config/contacts.hpp" +#include "util.h" + +inline session::config::Contacts *ptrToContacts(JNIEnv *env, jobject obj) { + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/Contacts"); + jfieldID pointerField = env->GetFieldID(contactsClass, "pointer", "J"); + return (session::config::Contacts *) env->GetLongField(obj, pointerField); +} + +inline jobject serialize_contact(JNIEnv *env, session::config::contact_info info) { + jclass contactClass = env->FindClass("network/loki/messenger/libsession_util/util/Contact"); + jmethodID constructor = env->GetMethodID(contactClass, "<init>", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZZLnetwork/loki/messenger/libsession_util/util/UserPic;ILnetwork/loki/messenger/libsession_util/util/ExpiryMode;)V"); + jstring id = env->NewStringUTF(info.session_id.data()); + jstring name = env->NewStringUTF(info.name.data()); + jstring nickname = env->NewStringUTF(info.nickname.data()); + jboolean approved, approvedMe, blocked; + approved = info.approved; + approvedMe = info.approved_me; + blocked = info.blocked; + auto created = info.created; + jobject profilePic = util::serialize_user_pic(env, info.profile_picture); + jobject returnObj = env->NewObject(contactClass, constructor, id, name, nickname, approved, + approvedMe, blocked, profilePic, info.priority, + util::serialize_expiry(env, info.exp_mode, info.exp_timer)); + return returnObj; +} + +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; + getId = env->GetFieldID(contactClass, "id", "Ljava/lang/String;"); + getName = env->GetFieldID(contactClass, "name", "Ljava/lang/String;"); + getNick = env->GetFieldID(contactClass, "nickname", "Ljava/lang/String;"); + getApproved = env->GetFieldID(contactClass, "approved", "Z"); + getApprovedMe = env->GetFieldID(contactClass, "approvedMe", "Z"); + getBlocked = env->GetFieldID(contactClass, "blocked", "Z"); + getUserPic = env->GetFieldID(contactClass, "profilePicture", + "Lnetwork/loki/messenger/libsession_util/util/UserPic;"); + getPriority = env->GetFieldID(contactClass, "priority", "I"); + getExpiry = env->GetFieldID(contactClass, "expiryMode", "Lnetwork/loki/messenger/libsession_util/util/ExpiryMode;"); + jstring name, nickname, session_id; + session_id = static_cast<jstring>(env->GetObjectField(info, getId)); + name = static_cast<jstring>(env->GetObjectField(info, getName)); + nickname = static_cast<jstring>(env->GetObjectField(info, getNick)); + bool approved, approvedMe, blocked, hidden; + int priority = env->GetIntField(info, getPriority); + approved = env->GetBooleanField(info, getApproved); + approvedMe = env->GetBooleanField(info, getApprovedMe); + blocked = env->GetBooleanField(info, getBlocked); + jobject user_pic = env->GetObjectField(info, getUserPic); + jobject expiry_mode = env->GetObjectField(info, getExpiry); + + auto expiry_pair = util::deserialize_expiry(env, expiry_mode); + + std::string url; + session::ustring key; + + if (user_pic != nullptr) { + auto deserialized_pic = util::deserialize_user_pic(env, user_pic); + auto url_jstring = deserialized_pic.first; + auto url_bytes = env->GetStringUTFChars(url_jstring, nullptr); + url = std::string(url_bytes); + env->ReleaseStringUTFChars(url_jstring, url_bytes); + key = util::ustring_from_bytes(env, deserialized_pic.second); + } + + auto session_id_bytes = env->GetStringUTFChars(session_id, nullptr); + auto name_bytes = name ? env->GetStringUTFChars(name, nullptr) : nullptr; + auto nickname_bytes = nickname ? env->GetStringUTFChars(nickname, nullptr) : nullptr; + + auto contact_info = conf->get_or_construct(session_id_bytes); + if (name_bytes) { + contact_info.name = name_bytes; + } + if (nickname_bytes) { + contact_info.nickname = nickname_bytes; + } + contact_info.approved = approved; + contact_info.approved_me = approvedMe; + contact_info.blocked = blocked; + if (!url.empty() && !key.empty()) { + contact_info.profile_picture = session::config::profile_pic(url, key); + } else { + contact_info.profile_picture = session::config::profile_pic(); + } + + env->ReleaseStringUTFChars(session_id, session_id_bytes); + if (name_bytes) { + env->ReleaseStringUTFChars(name, name_bytes); + } + if (nickname_bytes) { + env->ReleaseStringUTFChars(nickname, nickname_bytes); + } + + contact_info.priority = priority; + contact_info.exp_mode = expiry_pair.first; + contact_info.exp_timer = std::chrono::seconds(expiry_pair.second); + + return contact_info; +} + + +#endif //SESSION_ANDROID_CONTACTS_H diff --git a/libsession-util/src/main/cpp/conversation.cpp b/libsession-util/src/main/cpp/conversation.cpp new file mode 100644 index 0000000000..4f0f531dea --- /dev/null +++ b/libsession-util/src/main/cpp/conversation.cpp @@ -0,0 +1,352 @@ +#include <jni.h> +#include "conversation.h" + +#pragma clang diagnostic push + +extern "C" +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_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* convo_info_volatile = new session::config::ConvoInfoVolatile(secret_key, std::nullopt); + + jclass convoClass = env->FindClass("network/loki/messenger/libsession_util/ConversationVolatileConfig"); + jmethodID constructor = env->GetMethodID(convoClass, "<init>", "(J)V"); + jobject newConfig = env->NewObject(convoClass, constructor, reinterpret_cast<jlong>(convo_info_volatile)); + + return newConfig; +} +extern "C" +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_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); + auto* convo_info_volatile = new session::config::ConvoInfoVolatile(secret_key, initial); + + jclass convoClass = env->FindClass("network/loki/messenger/libsession_util/ConversationVolatileConfig"); + jmethodID constructor = env->GetMethodID(convoClass, "<init>", "(J)V"); + jobject newConfig = env->NewObject(convoClass, constructor, reinterpret_cast<jlong>(convo_info_volatile)); + + return newConfig; +} + + + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_sizeOneToOnes(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conversations = ptrToConvoInfo(env, thiz); + return conversations->size_1to1(); +} + +#pragma clang diagnostic pop +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseAll(JNIEnv *env, + jobject thiz, + jobject predicate) { + std::lock_guard lock{util::util_mutex_}; + auto conversations = ptrToConvoInfo(env, thiz); + + jclass predicate_class = env->FindClass("kotlin/jvm/functions/Function1"); + jmethodID predicate_call = env->GetMethodID(predicate_class, "invoke", "(Ljava/lang/Object;)Ljava/lang/Object;"); + + jclass bool_class = env->FindClass("java/lang/Boolean"); + jmethodID bool_get = env->GetMethodID(bool_class, "booleanValue", "()Z"); + + int removed = 0; + auto to_erase = std::vector<session::config::convo::any>(); + + for (auto it = conversations->begin(); it != conversations->end(); ++it) { + auto result = env->CallObjectMethod(predicate, predicate_call, serialize_any(env, *it)); + bool bool_result = env->CallBooleanMethod(result, bool_get); + if (bool_result) { + to_erase.push_back(*it); + } + } + + for (auto & entry : to_erase) { + if (conversations->erase(entry)) { + removed++; + } + } + + return removed; +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_size(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConvoInfo(env, thiz); + return (jint)config->size(); +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_empty(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto config = ptrToConvoInfo(env, thiz); + return config->empty(); +} +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_set(JNIEnv *env, + jobject thiz, + jobject to_store) { + std::lock_guard lock{util::util_mutex_}; + + auto convos = ptrToConvoInfo(env, thiz); + + jclass one_to_one = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$OneToOne"); + jclass open_group = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$Community"); + jclass legacy_closed_group = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$LegacyGroup"); + + jclass to_store_class = env->GetObjectClass(to_store); + if (env->IsSameObject(to_store_class, one_to_one)) { + // store as 1to1 + convos->set(deserialize_one_to_one(env, to_store, convos)); + } else if (env->IsSameObject(to_store_class,open_group)) { + // store as open_group + convos->set(deserialize_community(env, to_store, convos)); + } else if (env->IsSameObject(to_store_class,legacy_closed_group)) { + // store as legacy_closed_group + convos->set(deserialize_legacy_closed_group(env, to_store, convos)); + } +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOneToOne(JNIEnv *env, + jobject thiz, + jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto param = env->GetStringUTFChars(pub_key_hex, nullptr); + auto internal = convos->get_1to1(param); + env->ReleaseStringUTFChars(pub_key_hex, param); + if (internal) { + return serialize_one_to_one(env, *internal); + } + return nullptr; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOrConstructOneToOne( + JNIEnv *env, jobject thiz, jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto param = env->GetStringUTFChars(pub_key_hex, nullptr); + auto internal = convos->get_or_construct_1to1(param); + env->ReleaseStringUTFChars(pub_key_hex, param); + return serialize_one_to_one(env, internal); +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseOneToOne(JNIEnv *env, + jobject thiz, + jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto param = env->GetStringUTFChars(pub_key_hex, nullptr); + auto result = convos->erase_1to1(param); + env->ReleaseStringUTFChars(pub_key_hex, param); + return result; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getCommunity__Ljava_lang_String_2Ljava_lang_String_2( + JNIEnv *env, jobject thiz, jstring base_url, jstring room) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto open = convos->get_community(base_url_chars, room_chars); + if (open) { + auto serialized = serialize_open_group(env, *open); + return serialized; + } + return nullptr; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOrConstructCommunity__Ljava_lang_String_2Ljava_lang_String_2_3B( + JNIEnv *env, jobject thiz, jstring base_url, jstring room, jbyteArray pub_key) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto pub_key_ustring = util::ustring_from_bytes(env, pub_key); + auto open = convos->get_or_construct_community(base_url_chars, room_chars, pub_key_ustring); + auto serialized = serialize_open_group(env, open); + return serialized; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOrConstructCommunity__Ljava_lang_String_2Ljava_lang_String_2Ljava_lang_String_2( + JNIEnv *env, jobject thiz, jstring base_url, jstring room, jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto hex_chars = env->GetStringUTFChars(pub_key_hex, nullptr); + auto open = convos->get_or_construct_community(base_url_chars, room_chars, hex_chars); + env->ReleaseStringUTFChars(base_url, base_url_chars); + env->ReleaseStringUTFChars(room, room_chars); + env->ReleaseStringUTFChars(pub_key_hex, hex_chars); + auto serialized = serialize_open_group(env, open); + return serialized; +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseCommunity__Lnetwork_loki_messenger_libsession_1util_util_Conversation_Community_2(JNIEnv *env, + jobject thiz, + jobject open_group) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto deserialized = deserialize_community(env, open_group, convos); + return convos->erase(deserialized); +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseCommunity__Ljava_lang_String_2Ljava_lang_String_2( + JNIEnv *env, jobject thiz, jstring base_url, jstring room) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto result = convos->erase_community(base_url_chars, room_chars); + env->ReleaseStringUTFChars(base_url, base_url_chars); + env->ReleaseStringUTFChars(room, room_chars); + return result; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getLegacyClosedGroup( + JNIEnv *env, jobject thiz, jstring group_id) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto id_chars = env->GetStringUTFChars(group_id, nullptr); + auto lgc = convos->get_legacy_group(id_chars); + env->ReleaseStringUTFChars(group_id, id_chars); + if (lgc) { + auto serialized = serialize_legacy_group(env, *lgc); + return serialized; + } + return nullptr; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_getOrConstructLegacyGroup( + JNIEnv *env, jobject thiz, jstring group_id) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto id_chars = env->GetStringUTFChars(group_id, nullptr); + auto lgc = convos->get_or_construct_legacy_group(id_chars); + env->ReleaseStringUTFChars(group_id, id_chars); + return serialize_legacy_group(env, lgc); +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_eraseLegacyClosedGroup( + JNIEnv *env, jobject thiz, jstring group_id) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto id_chars = env->GetStringUTFChars(group_id, nullptr); + auto result = convos->erase_legacy_group(id_chars); + env->ReleaseStringUTFChars(group_id, id_chars); + return result; +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_erase(JNIEnv *env, + jobject thiz, + jobject conversation) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + auto deserialized = deserialize_any(env, conversation, convos); + if (!deserialized.has_value()) return false; + return convos->erase(*deserialized); +} +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_sizeCommunities(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + return convos->size_communities(); +} +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_sizeLegacyClosedGroups( + JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(env, thiz); + return convos->size_legacy_groups(); +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_all(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(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& convo : *convos) { + auto contact_obj = serialize_any(env, convo); + env->CallObjectMethod(our_stack, push, contact_obj); + } + return our_stack; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_allOneToOnes(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(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 (auto contact = convos->begin_1to1(); contact != convos->end(); ++contact) + env->CallObjectMethod(our_stack, push, serialize_one_to_one(env, *contact)); + return our_stack; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_allCommunities(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(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 (auto contact = convos->begin_communities(); contact != convos->end(); ++contact) + env->CallObjectMethod(our_stack, push, serialize_open_group(env, *contact)); + return our_stack; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_ConversationVolatileConfig_allLegacyClosedGroups( + JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto convos = ptrToConvoInfo(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 (auto contact = convos->begin_legacy_groups(); contact != convos->end(); ++contact) + env->CallObjectMethod(our_stack, push, serialize_legacy_group(env, *contact)); + return our_stack; +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/conversation.h b/libsession-util/src/main/cpp/conversation.h new file mode 100644 index 0000000000..45e453a595 --- /dev/null +++ b/libsession-util/src/main/cpp/conversation.h @@ -0,0 +1,122 @@ +#ifndef SESSION_ANDROID_CONVERSATION_H +#define SESSION_ANDROID_CONVERSATION_H + +#include <jni.h> +#include "util.h" +#include "session/config/convo_info_volatile.hpp" + +inline session::config::ConvoInfoVolatile *ptrToConvoInfo(JNIEnv *env, jobject obj) { + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/ConversationVolatileConfig"); + jfieldID pointerField = env->GetFieldID(contactsClass, "pointer", "J"); + return (session::config::ConvoInfoVolatile *) env->GetLongField(obj, pointerField); +} + +inline jobject serialize_one_to_one(JNIEnv *env, session::config::convo::one_to_one one_to_one) { + jclass clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$OneToOne"); + jmethodID constructor = env->GetMethodID(clazz, "<init>", "(Ljava/lang/String;JZ)V"); + auto session_id = env->NewStringUTF(one_to_one.session_id.data()); + auto last_read = one_to_one.last_read; + auto unread = one_to_one.unread; + jobject serialized = env->NewObject(clazz, constructor, session_id, last_read, unread); + return serialized; +} + +inline jobject serialize_open_group(JNIEnv *env, session::config::convo::community community) { + jclass clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$Community"); + auto base_community = util::serialize_base_community(env, community); + jmethodID constructor = env->GetMethodID(clazz, "<init>", + "(Lnetwork/loki/messenger/libsession_util/util/BaseCommunityInfo;JZ)V"); + auto last_read = community.last_read; + auto unread = community.unread; + jobject serialized = env->NewObject(clazz, constructor, base_community, last_read, unread); + return serialized; +} + +inline jobject serialize_legacy_group(JNIEnv *env, session::config::convo::legacy_group group) { + jclass clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$LegacyGroup"); + jmethodID constructor = env->GetMethodID(clazz, "<init>", "(Ljava/lang/String;JZ)V"); + auto group_id = env->NewStringUTF(group.id.data()); + auto last_read = group.last_read; + auto unread = group.unread; + jobject serialized = env->NewObject(clazz, constructor, group_id, last_read, unread); + return serialized; +} + +inline jobject serialize_any(JNIEnv *env, session::config::convo::any any) { + if (auto* dm = std::get_if<session::config::convo::one_to_one>(&any)) { + return serialize_one_to_one(env, *dm); + } else if (auto* og = std::get_if<session::config::convo::community>(&any)) { + return serialize_open_group(env, *og); + } else if (auto* lgc = std::get_if<session::config::convo::legacy_group>(&any)) { + return serialize_legacy_group(env, *lgc); + } + return nullptr; +} + +inline session::config::convo::one_to_one deserialize_one_to_one(JNIEnv *env, jobject info, session::config::ConvoInfoVolatile *conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$OneToOne"); + auto id_getter = env->GetFieldID(clazz, "sessionId", "Ljava/lang/String;"); + auto last_read_getter = env->GetFieldID(clazz, "lastRead", "J"); + auto unread_getter = env->GetFieldID(clazz, "unread", "Z"); + jstring id = static_cast<jstring>(env->GetObjectField(info, id_getter)); + auto id_chars = env->GetStringUTFChars(id, nullptr); + std::string id_string = std::string{id_chars}; + auto deserialized = conf->get_or_construct_1to1(id_string); + deserialized.last_read = env->GetLongField(info, last_read_getter); + deserialized.unread = env->GetBooleanField(info, unread_getter); + env->ReleaseStringUTFChars(id, id_chars); + return deserialized; +} + +inline session::config::convo::community deserialize_community(JNIEnv *env, jobject info, session::config::ConvoInfoVolatile *conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$Community"); + auto base_community_getter = env->GetFieldID(clazz, "baseCommunityInfo", "Lnetwork/loki/messenger/libsession_util/util/BaseCommunityInfo;"); + auto last_read_getter = env->GetFieldID(clazz, "lastRead", "J"); + auto unread_getter = env->GetFieldID(clazz, "unread", "Z"); + + auto base_community_info = env->GetObjectField(info, base_community_getter); + + auto base_community_deserialized = util::deserialize_base_community(env, base_community_info); + auto deserialized = conf->get_or_construct_community( + base_community_deserialized.base_url(), + base_community_deserialized.room(), + base_community_deserialized.pubkey() + ); + + deserialized.last_read = env->GetLongField(info, last_read_getter); + deserialized.unread = env->GetBooleanField(info, unread_getter); + + return deserialized; +} + +inline session::config::convo::legacy_group deserialize_legacy_closed_group(JNIEnv *env, jobject info, session::config::ConvoInfoVolatile *conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$LegacyGroup"); + auto group_id_getter = env->GetFieldID(clazz, "groupId", "Ljava/lang/String;"); + auto last_read_getter = env->GetFieldID(clazz, "lastRead", "J"); + auto unread_getter = env->GetFieldID(clazz, "unread", "Z"); + auto group_id = static_cast<jstring>(env->GetObjectField(info, group_id_getter)); + auto group_id_bytes = env->GetStringUTFChars(group_id, nullptr); + auto group_id_string = std::string{group_id_bytes}; + auto deserialized = conf->get_or_construct_legacy_group(group_id_string); + deserialized.last_read = env->GetLongField(info, last_read_getter); + deserialized.unread = env->GetBooleanField(info, unread_getter); + env->ReleaseStringUTFChars(group_id, group_id_bytes); + return deserialized; +} + +inline std::optional<session::config::convo::any> deserialize_any(JNIEnv *env, jobject convo, session::config::ConvoInfoVolatile *conf) { + auto oto_class = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$OneToOne"); + auto og_class = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$Community"); + auto lgc_class = env->FindClass("network/loki/messenger/libsession_util/util/Conversation$LegacyGroup"); + auto object_class = env->GetObjectClass(convo); + if (env->IsSameObject(object_class, oto_class)) { + return session::config::convo::any{deserialize_one_to_one(env, convo, conf)}; + } else if (env->IsSameObject(object_class, og_class)) { + return session::config::convo::any{deserialize_community(env, convo, conf)}; + } else if (env->IsSameObject(object_class, lgc_class)) { + return session::config::convo::any{deserialize_legacy_closed_group(env, convo, conf)}; + } + return std::nullopt; +} + +#endif //SESSION_ANDROID_CONVERSATION_H \ No newline at end of file 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 new file mode 100644 index 0000000000..9754b40891 --- /dev/null +++ b/libsession-util/src/main/cpp/user_groups.cpp @@ -0,0 +1,274 @@ +#pragma clang diagnostic push +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +#include "user_groups.h" + + +#pragma clang diagnostic push +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_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* user_groups = new session::config::UserGroups(secret_key, std::nullopt); + + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/UserGroupsConfig"); + jmethodID constructor = env->GetMethodID(contactsClass, "<init>", "(J)V"); + jobject newConfig = env->NewObject(contactsClass, constructor, reinterpret_cast<jlong>(user_groups)); + + return newConfig; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_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); + + auto* user_groups = new session::config::UserGroups(secret_key, initial); + + jclass contactsClass = env->FindClass("network/loki/messenger/libsession_util/UserGroupsConfig"); + jmethodID constructor = env->GetMethodID(contactsClass, "<init>", "(J)V"); + jobject newConfig = env->NewObject(contactsClass, constructor, reinterpret_cast<jlong>(user_groups)); + + return newConfig; +} +#pragma clang diagnostic pop + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_util_GroupInfo_00024LegacyGroupInfo_00024Companion_NAME_1MAX_1LENGTH( + JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + return session::config::legacy_group_info::NAME_MAX_LENGTH; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_getCommunityInfo(JNIEnv *env, + jobject thiz, + jstring base_url, + jstring room) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto base_url_bytes = env->GetStringUTFChars(base_url, nullptr); + auto room_bytes = env->GetStringUTFChars(room, nullptr); + + auto community = conf->get_community(base_url_bytes, room_bytes); + + jobject community_info = nullptr; + + if (community) { + community_info = serialize_community_info(env, *community); + } + env->ReleaseStringUTFChars(base_url, base_url_bytes); + env->ReleaseStringUTFChars(room, room_bytes); + return community_info; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_getLegacyGroupInfo(JNIEnv *env, + jobject thiz, + jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto id_bytes = env->GetStringUTFChars(session_id, nullptr); + auto legacy_group = conf->get_legacy_group(id_bytes); + jobject return_group = nullptr; + if (legacy_group) { + return_group = serialize_legacy_group_info(env, *legacy_group); + } + env->ReleaseStringUTFChars(session_id, id_bytes); + return return_group; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_getOrConstructCommunityInfo( + JNIEnv *env, jobject thiz, jstring base_url, jstring room, jstring pub_key_hex) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto base_url_bytes = env->GetStringUTFChars(base_url, nullptr); + auto room_bytes = env->GetStringUTFChars(room, nullptr); + auto pub_hex_bytes = env->GetStringUTFChars(pub_key_hex, nullptr); + + auto group = conf->get_or_construct_community(base_url_bytes, room_bytes, pub_hex_bytes); + + env->ReleaseStringUTFChars(base_url, base_url_bytes); + env->ReleaseStringUTFChars(room, room_bytes); + env->ReleaseStringUTFChars(pub_key_hex, pub_hex_bytes); + return serialize_community_info(env, group); +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_getOrConstructLegacyGroupInfo( + JNIEnv *env, jobject thiz, jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto id_bytes = env->GetStringUTFChars(session_id, nullptr); + auto group = conf->get_or_construct_legacy_group(id_bytes); + env->ReleaseStringUTFChars(session_id, id_bytes); + return serialize_legacy_group_info(env, group); +} + +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_set__Lnetwork_loki_messenger_libsession_1util_util_GroupInfo_2( + JNIEnv *env, jobject thiz, jobject group_info) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto community_info = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$CommunityGroupInfo"); + auto legacy_info = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$LegacyGroupInfo"); + auto object_class = env->GetObjectClass(group_info); + if (env->IsSameObject(community_info, object_class)) { + auto deserialized = deserialize_community_info(env, group_info, conf); + conf->set(deserialized); + } else if (env->IsSameObject(legacy_info, object_class)) { + auto deserialized = deserialize_legacy_group_info(env, group_info, conf); + conf->set(deserialized); + } +} + + +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_erase__Lnetwork_loki_messenger_libsession_1util_util_GroupInfo_2( + JNIEnv *env, jobject thiz, jobject group_info) { + std::lock_guard lock{util::util_mutex_}; + 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"); + 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->IsSameObject(group_object, legacyInfo)) { + auto deserialized = deserialize_legacy_group_info(env, group_info, conf); + conf->erase(deserialized); + } +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_sizeCommunityInfo(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + return conf->size_communities(); +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_sizeLegacyGroupInfo(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + return conf->size_legacy_groups(); +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_size(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToConvoInfo(env, thiz); + return conf->size(); +} + +inline jobject iterator_as_java_stack(JNIEnv *env, const session::config::UserGroups::iterator& begin, const session::config::UserGroups::iterator& end) { + 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 (auto it = begin; it != end;) { + // do something with it + auto item = *it; + jobject serialized = nullptr; + if (auto* lgc = std::get_if<session::config::legacy_group_info>(&item)) { + serialized = serialize_legacy_group_info(env, *lgc); + } else if (auto* community = std::get_if<session::config::community_info>(&item)) { + serialized = serialize_community_info(env, *community); + } + if (serialized != nullptr) { + env->CallObjectMethod(our_stack, push, serialized); + } + it++; + } + return our_stack; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_all(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + jobject all_stack = iterator_as_java_stack(env, conf->begin(), conf->end()); + return all_stack; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_allCommunityInfo(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + jobject community_stack = iterator_as_java_stack(env, conf->begin_communities(), conf->end()); + return community_stack; +} + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_allLegacyGroupInfo(JNIEnv *env, + jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + jobject legacy_stack = iterator_as_java_stack(env, conf->begin_legacy_groups(), conf->end()); + return legacy_stack; +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_eraseCommunity__Lnetwork_loki_messenger_libsession_1util_util_BaseCommunityInfo_2(JNIEnv *env, + jobject thiz, + jobject base_community_info) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto base_community = util::deserialize_base_community(env, base_community_info); + return conf->erase_community(base_community.base_url(),base_community.room()); +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_eraseCommunity__Ljava_lang_String_2Ljava_lang_String_2( + JNIEnv *env, jobject thiz, jstring server, jstring room) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto server_bytes = env->GetStringUTFChars(server, nullptr); + auto room_bytes = env->GetStringUTFChars(room, nullptr); + auto community = conf->get_community(server_bytes, room_bytes); + bool deleted = false; + if (community) { + deleted = conf->erase(*community); + } + env->ReleaseStringUTFChars(server, server_bytes); + env->ReleaseStringUTFChars(room, room_bytes); + return deleted; +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_network_loki_messenger_libsession_1util_UserGroupsConfig_eraseLegacyGroup(JNIEnv *env, + jobject thiz, + jstring session_id) { + std::lock_guard lock{util::util_mutex_}; + auto conf = ptrToUserGroups(env, thiz); + auto session_id_bytes = env->GetStringUTFChars(session_id, nullptr); + bool return_bool = conf->erase_legacy_group(session_id_bytes); + env->ReleaseStringUTFChars(session_id, session_id_bytes); + return return_bool; +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/user_groups.h b/libsession-util/src/main/cpp/user_groups.h new file mode 100644 index 0000000000..c4754fe113 --- /dev/null +++ b/libsession-util/src/main/cpp/user_groups.h @@ -0,0 +1,139 @@ + +#ifndef SESSION_ANDROID_USER_GROUPS_H +#define SESSION_ANDROID_USER_GROUPS_H + +#include "jni.h" +#include "util.h" +#include "conversation.h" +#include "session/config/user_groups.hpp" + +inline session::config::UserGroups* ptrToUserGroups(JNIEnv *env, jobject obj) { + jclass configClass = env->FindClass("network/loki/messenger/libsession_util/UserGroupsConfig"); + jfieldID pointerField = env->GetFieldID(configClass, "pointer", "J"); + return (session::config::UserGroups*) env->GetLongField(obj, pointerField); +} + +inline void deserialize_members_into(JNIEnv *env, jobject members_map, session::config::legacy_group_info& to_append_group) { + jclass map_class = env->FindClass("java/util/Map"); + jclass map_entry_class = env->FindClass("java/util/Map$Entry"); + jclass set_class = env->FindClass("java/util/Set"); + jclass iterator_class = env->FindClass("java/util/Iterator"); + jclass boxed_bool = env->FindClass("java/lang/Boolean"); + + jmethodID get_entry_set = env->GetMethodID(map_class, "entrySet", "()Ljava/util/Set;"); + jmethodID get_at = env->GetMethodID(set_class, "iterator", "()Ljava/util/Iterator;"); + jmethodID has_next = env->GetMethodID(iterator_class, "hasNext", "()Z"); + jmethodID next = env->GetMethodID(iterator_class, "next", "()Ljava/lang/Object;"); + jmethodID get_key = env->GetMethodID(map_entry_class, "getKey", "()Ljava/lang/Object;"); + jmethodID get_value = env->GetMethodID(map_entry_class, "getValue", "()Ljava/lang/Object;"); + jmethodID get_bool_value = env->GetMethodID(boxed_bool, "booleanValue", "()Z"); + + jobject entry_set = env->CallObjectMethod(members_map, get_entry_set); + jobject iterator = env->CallObjectMethod(entry_set, get_at); + + while (env->CallBooleanMethod(iterator, has_next)) { + jobject entry = env->CallObjectMethod(iterator, next); + jstring key = static_cast<jstring>(env->CallObjectMethod(entry, get_key)); + jobject boxed = env->CallObjectMethod(entry, get_value); + bool is_admin = env->CallBooleanMethod(boxed, get_bool_value); + auto member_string = env->GetStringUTFChars(key, nullptr); + to_append_group.insert(member_string, is_admin); + env->ReleaseStringUTFChars(key, member_string); + } +} + +inline session::config::legacy_group_info deserialize_legacy_group_info(JNIEnv *env, jobject info, session::config::UserGroups* conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$LegacyGroupInfo"); + auto id_field = env->GetFieldID(clazz, "sessionId", "Ljava/lang/String;"); + auto name_field = env->GetFieldID(clazz, "name", "Ljava/lang/String;"); + auto members_field = env->GetFieldID(clazz, "members", "Ljava/util/Map;"); + auto enc_pub_key_field = env->GetFieldID(clazz, "encPubKey", "[B"); + auto enc_sec_key_field = env->GetFieldID(clazz, "encSecKey", "[B"); + auto priority_field = env->GetFieldID(clazz, "priority", "I"); + auto disappearing_timer_field = env->GetFieldID(clazz, "disappearingTimer", "J"); + auto joined_at_field = env->GetFieldID(clazz, "joinedAt", "J"); + jstring id = static_cast<jstring>(env->GetObjectField(info, id_field)); + jstring name = static_cast<jstring>(env->GetObjectField(info, name_field)); + jobject members_map = env->GetObjectField(info, members_field); + jbyteArray enc_pub_key = static_cast<jbyteArray>(env->GetObjectField(info, enc_pub_key_field)); + jbyteArray enc_sec_key = static_cast<jbyteArray>(env->GetObjectField(info, enc_sec_key_field)); + int priority = env->GetIntField(info, priority_field); + long joined_at = env->GetLongField(info, joined_at_field); + + auto id_bytes = env->GetStringUTFChars(id, nullptr); + auto name_bytes = env->GetStringUTFChars(name, nullptr); + auto enc_pub_key_bytes = util::ustring_from_bytes(env, enc_pub_key); + auto enc_sec_key_bytes = util::ustring_from_bytes(env, enc_sec_key); + + auto info_deserialized = conf->get_or_construct_legacy_group(id_bytes); + + auto current_members = info_deserialized.members(); + for (auto member = current_members.begin(); member != current_members.end(); ++member) { + info_deserialized.erase(member->first); + } + deserialize_members_into(env, members_map, info_deserialized); + info_deserialized.name = name_bytes; + info_deserialized.enc_pubkey = enc_pub_key_bytes; + info_deserialized.enc_seckey = enc_sec_key_bytes; + info_deserialized.priority = priority; + info_deserialized.disappearing_timer = std::chrono::seconds(env->GetLongField(info, disappearing_timer_field)); + info_deserialized.joined_at = joined_at; + env->ReleaseStringUTFChars(id, id_bytes); + env->ReleaseStringUTFChars(name, name_bytes); + return info_deserialized; +} + +inline session::config::community_info deserialize_community_info(JNIEnv *env, jobject info, session::config::UserGroups* conf) { + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$CommunityGroupInfo"); + auto base_info = env->GetFieldID(clazz, "community", "Lnetwork/loki/messenger/libsession_util/util/BaseCommunityInfo;"); + auto priority = env->GetFieldID(clazz, "priority", "I"); + jobject base_community_info = env->GetObjectField(info, base_info); + auto deserialized_base_info = util::deserialize_base_community(env, base_community_info); + int deserialized_priority = env->GetIntField(info, priority); + auto community_info = conf->get_or_construct_community(deserialized_base_info.base_url(), deserialized_base_info.room(), deserialized_base_info.pubkey_hex()); + community_info.priority = deserialized_priority; + return community_info; +} + +inline jobject serialize_members(JNIEnv *env, std::map<std::string, bool> members_map) { + jclass map_class = env->FindClass("java/util/HashMap"); + jclass boxed_bool = env->FindClass("java/lang/Boolean"); + jmethodID map_constructor = env->GetMethodID(map_class, "<init>", "()V"); + jmethodID insert = env->GetMethodID(map_class, "put", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"); + jmethodID new_bool = env->GetMethodID(boxed_bool, "<init>", "(Z)V"); + + jobject new_map = env->NewObject(map_class, map_constructor); + for (auto it = members_map.begin(); it != members_map.end(); it++) { + auto session_id = env->NewStringUTF(it->first.data()); + bool is_admin = it->second; + auto jbool = env->NewObject(boxed_bool, new_bool, is_admin); + env->CallObjectMethod(new_map, insert, session_id, jbool); + } + return new_map; +} + +inline jobject serialize_legacy_group_info(JNIEnv *env, session::config::legacy_group_info info) { + jstring session_id = env->NewStringUTF(info.session_id.data()); + jstring name = env->NewStringUTF(info.name.data()); + jobject members = serialize_members(env, info.members()); + jbyteArray enc_pubkey = util::bytes_from_ustring(env, info.enc_pubkey); + jbyteArray enc_seckey = util::bytes_from_ustring(env, info.enc_seckey); + int priority = info.priority; + long joined_at = info.joined_at; + + jclass legacy_group_class = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$LegacyGroupInfo"); + jmethodID constructor = env->GetMethodID(legacy_group_class, "<init>", "(Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;[B[BIJJ)V"); + jobject serialized = env->NewObject(legacy_group_class, constructor, session_id, name, members, enc_pubkey, enc_seckey, priority, (jlong) info.disappearing_timer.count(), joined_at); + return serialized; +} + +inline jobject serialize_community_info(JNIEnv *env, session::config::community_info info) { + auto priority = info.priority; + auto serialized_info = util::serialize_base_community(env, info); + auto clazz = env->FindClass("network/loki/messenger/libsession_util/util/GroupInfo$CommunityGroupInfo"); + jmethodID constructor = env->GetMethodID(clazz, "<init>", "(Lnetwork/loki/messenger/libsession_util/util/BaseCommunityInfo;I)V"); + jobject serialized = env->NewObject(clazz, constructor, serialized_info, priority); + return serialized; +} + +#endif //SESSION_ANDROID_USER_GROUPS_H diff --git a/libsession-util/src/main/cpp/user_profile.cpp b/libsession-util/src/main/cpp/user_profile.cpp new file mode 100644 index 0000000000..9f5a9e9d36 --- /dev/null +++ b/libsession-util/src/main/cpp/user_profile.cpp @@ -0,0 +1,152 @@ +#include "user_profile.h" +#include "util.h" + +extern "C" { +#pragma clang diagnostic push +#pragma ide diagnostic ignored "bugprone-reserved-identifier" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_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); + auto* profile = new session::config::UserProfile(secret_key, std::optional(initial)); + + jclass userClass = env->FindClass("network/loki/messenger/libsession_util/UserProfile"); + jmethodID constructor = env->GetMethodID(userClass, "<init>", "(J)V"); + jobject newConfig = env->NewObject(userClass, constructor, reinterpret_cast<jlong>(profile)); + + return newConfig; +} + +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_00024Companion_newInstance___3B( + JNIEnv* env, + jobject, + jbyteArray secretKey) { + std::lock_guard lock{util::util_mutex_}; + auto* profile = new session::config::UserProfile(util::ustring_from_bytes(env, secretKey), std::nullopt); + + jclass userClass = env->FindClass("network/loki/messenger/libsession_util/UserProfile"); + jmethodID constructor = env->GetMethodID(userClass, "<init>", "(J)V"); + jobject newConfig = env->NewObject(userClass, constructor, reinterpret_cast<jlong>(profile)); + + return newConfig; +} +#pragma clang diagnostic pop + +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_setName( + JNIEnv* env, + jobject thiz, + jstring newName) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto name_chars = env->GetStringUTFChars(newName, nullptr); + profile->set_name(name_chars); + env->ReleaseStringUTFChars(newName, name_chars); +} + +JNIEXPORT jstring JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_getName(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto name = profile->get_name(); + if (name == std::nullopt) return nullptr; + jstring returnString = env->NewStringUTF(name->data()); + return returnString; +} + +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_getPic(JNIEnv *env, jobject thiz) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto pic = profile->get_profile_pic(); + + jobject returnObject = util::serialize_user_pic(env, pic); + + return returnObject; +} + +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_setPic(JNIEnv *env, jobject thiz, + jobject user_pic) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + auto pic = util::deserialize_user_pic(env, user_pic); + auto url = env->GetStringUTFChars(pic.first, nullptr); + auto key = util::ustring_from_bytes(env, pic.second); + profile->set_profile_pic(url, key); + env->ReleaseStringUTFChars(pic.first, url); +} + +} +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_setNtsPriority(JNIEnv *env, jobject thiz, + jint priority) { + std::lock_guard lock{util::util_mutex_}; + auto profile = ptrToProfile(env, thiz); + profile->set_nts_priority(priority); +} +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_UserProfile_getNtsPriority(JNIEnv *env, jobject thiz) { + 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/user_profile.h b/libsession-util/src/main/cpp/user_profile.h new file mode 100644 index 0000000000..cb1b8d973b --- /dev/null +++ b/libsession-util/src/main/cpp/user_profile.h @@ -0,0 +1,14 @@ +#ifndef SESSION_ANDROID_USER_PROFILE_H +#define SESSION_ANDROID_USER_PROFILE_H + +#include "session/config/user_profile.hpp" +#include <jni.h> +#include <string> + +inline session::config::UserProfile* ptrToProfile(JNIEnv* env, jobject obj) { + jclass configClass = env->FindClass("network/loki/messenger/libsession_util/UserProfile"); + jfieldID pointerField = env->GetFieldID(configClass, "pointer", "J"); + return (session::config::UserProfile*) env->GetLongField(obj, pointerField); +} + +#endif \ No newline at end of file diff --git a/libsession-util/src/main/cpp/util.cpp b/libsession-util/src/main/cpp/util.cpp new file mode 100644 index 0000000000..602580f04a --- /dev/null +++ b/libsession-util/src/main/cpp/util.cpp @@ -0,0 +1,178 @@ +#include "util.h" +#include <string> +#include <sodium/crypto_sign.h> + +namespace util { + + std::mutex util_mutex_ = std::mutex(); + + jbyteArray bytes_from_ustring(JNIEnv* env, session::ustring_view from_str) { + size_t length = from_str.length(); + auto jlength = (jsize)length; + jbyteArray new_array = env->NewByteArray(jlength); + env->SetByteArrayRegion(new_array, 0, jlength, (jbyte*)from_str.data()); + return new_array; + } + + session::ustring ustring_from_bytes(JNIEnv* env, jbyteArray byteArray) { + size_t len = env->GetArrayLength(byteArray); + auto bytes = env->GetByteArrayElements(byteArray, nullptr); + + session::ustring st{reinterpret_cast<const unsigned char *>(bytes), len}; + env->ReleaseByteArrayElements(byteArray, bytes, 0); + return st; + } + + jobject serialize_user_pic(JNIEnv *env, session::config::profile_pic pic) { + jclass returnObjectClass = env->FindClass("network/loki/messenger/libsession_util/util/UserPic"); + jmethodID constructor = env->GetMethodID(returnObjectClass, "<init>", "(Ljava/lang/String;[B)V"); + jstring url = env->NewStringUTF(pic.url.data()); + jbyteArray byteArray = util::bytes_from_ustring(env, pic.key); + return env->NewObject(returnObjectClass, constructor, url, byteArray); + } + + std::pair<jstring, jbyteArray> deserialize_user_pic(JNIEnv *env, jobject user_pic) { + jclass userPicClass = env->FindClass("network/loki/messenger/libsession_util/util/UserPic"); + jfieldID picField = env->GetFieldID(userPicClass, "url", "Ljava/lang/String;"); + jfieldID keyField = env->GetFieldID(userPicClass, "key", "[B"); + auto pic = (jstring)env->GetObjectField(user_pic, picField); + auto key = (jbyteArray)env->GetObjectField(user_pic, keyField); + return {pic, key}; + } + + jobject serialize_base_community(JNIEnv *env, const session::config::community& community) { + jclass base_community_clazz = env->FindClass("network/loki/messenger/libsession_util/util/BaseCommunityInfo"); + jmethodID base_community_constructor = env->GetMethodID(base_community_clazz, "<init>", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"); + auto base_url = env->NewStringUTF(community.base_url().data()); + auto room = env->NewStringUTF(community.room().data()); + auto pubkey_jstring = env->NewStringUTF(community.pubkey_hex().data()); + jobject ret = env->NewObject(base_community_clazz, base_community_constructor, base_url, room, pubkey_jstring); + return ret; + } + + session::config::community deserialize_base_community(JNIEnv *env, jobject base_community) { + jclass base_community_clazz = env->FindClass("network/loki/messenger/libsession_util/util/BaseCommunityInfo"); + jfieldID base_url_field = env->GetFieldID(base_community_clazz, "baseUrl", "Ljava/lang/String;"); + jfieldID room_field = env->GetFieldID(base_community_clazz, "room", "Ljava/lang/String;"); + jfieldID pubkey_hex_field = env->GetFieldID(base_community_clazz, "pubKeyHex", "Ljava/lang/String;"); + auto base_url = (jstring)env->GetObjectField(base_community,base_url_field); + auto room = (jstring)env->GetObjectField(base_community, room_field); + auto pub_key_hex = (jstring)env->GetObjectField(base_community, pubkey_hex_field); + auto base_url_chars = env->GetStringUTFChars(base_url, nullptr); + auto room_chars = env->GetStringUTFChars(room, nullptr); + auto pub_key_hex_chars = env->GetStringUTFChars(pub_key_hex, nullptr); + + auto community = session::config::community(base_url_chars, room_chars, pub_key_hex_chars); + + env->ReleaseStringUTFChars(base_url, base_url_chars); + env->ReleaseStringUTFChars(room, room_chars); + env->ReleaseStringUTFChars(pub_key_hex, pub_key_hex_chars); + return community; + } + + jobject serialize_expiry(JNIEnv *env, const session::config::expiration_mode& mode, const std::chrono::seconds& time_seconds) { + jclass none = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$NONE"); + jfieldID none_instance = env->GetStaticFieldID(none, "INSTANCE", "Lnetwork/loki/messenger/libsession_util/util/ExpiryMode$NONE;"); + jclass after_send = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$AfterSend"); + jmethodID send_init = env->GetMethodID(after_send, "<init>", "(J)V"); + jclass after_read = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$AfterRead"); + jmethodID read_init = env->GetMethodID(after_read, "<init>", "(J)V"); + + if (mode == session::config::expiration_mode::none) { + return env->GetStaticObjectField(none, none_instance); + } else if (mode == session::config::expiration_mode::after_send) { + return env->NewObject(after_send, send_init, time_seconds.count()); + } else if (mode == session::config::expiration_mode::after_read) { + return env->NewObject(after_read, read_init, time_seconds.count()); + } + return nullptr; + } + + std::pair<session::config::expiration_mode, long> deserialize_expiry(JNIEnv *env, jobject expiry_mode) { + jclass parent = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode"); + jclass after_read = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$AfterRead"); + jclass after_send = env->FindClass("network/loki/messenger/libsession_util/util/ExpiryMode$AfterSend"); + jfieldID duration_seconds = env->GetFieldID(parent, "expirySeconds", "J"); + + jclass object_class = env->GetObjectClass(expiry_mode); + + if (env->IsSameObject(object_class, after_read)) { + return std::pair(session::config::expiration_mode::after_read, env->GetLongField(expiry_mode, duration_seconds)); + } 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" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_util_Sodium_ed25519KeyPair(JNIEnv *env, jobject thiz, jbyteArray seed) { + std::array<unsigned char, 32> ed_pk; // NOLINT(cppcoreguidelines-pro-type-member-init) + std::array<unsigned char, 64> ed_sk; // NOLINT(cppcoreguidelines-pro-type-member-init) + auto seed_bytes = util::ustring_from_bytes(env, seed); + crypto_sign_ed25519_seed_keypair(ed_pk.data(), ed_sk.data(), seed_bytes.data()); + + jclass kp_class = env->FindClass("network/loki/messenger/libsession_util/util/KeyPair"); + jmethodID kp_constructor = env->GetMethodID(kp_class, "<init>", "([B[B)V"); + + jbyteArray pk_jarray = util::bytes_from_ustring(env, session::ustring_view {ed_pk.data(), ed_pk.size()}); + jbyteArray sk_jarray = util::bytes_from_ustring(env, session::ustring_view {ed_sk.data(), ed_sk.size()}); + + jobject return_obj = env->NewObject(kp_class, kp_constructor, pk_jarray, sk_jarray); + return return_obj; +} +extern "C" +JNIEXPORT jbyteArray JNICALL +Java_network_loki_messenger_libsession_1util_util_Sodium_ed25519PkToCurve25519(JNIEnv *env, + jobject thiz, + jbyteArray pk) { + auto ed_pk = util::ustring_from_bytes(env, pk); + std::array<unsigned char, 32> curve_pk; // NOLINT(cppcoreguidelines-pro-type-member-init) + int success = crypto_sign_ed25519_pk_to_curve25519(curve_pk.data(), ed_pk.data()); + if (success != 0) { + jclass exception = env->FindClass("java/lang/Exception"); + env->ThrowNew(exception, "Invalid crypto_sign_ed25519_pk_to_curve25519 operation"); + return nullptr; + } + jbyteArray curve_pk_jarray = util::bytes_from_ustring(env, session::ustring_view {curve_pk.data(), curve_pk.size()}); + return curve_pk_jarray; +} +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_util_BaseCommunityInfo_00024Companion_parseFullUrl( + JNIEnv *env, jobject thiz, jstring full_url) { + auto bytes = env->GetStringUTFChars(full_url, nullptr); + auto [base, room, pk] = session::config::community::parse_full_url(bytes); + env->ReleaseStringUTFChars(full_url, bytes); + + jclass clazz = env->FindClass("kotlin/Triple"); + jmethodID constructor = env->GetMethodID(clazz, "<init>", "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V"); + + auto base_j = env->NewStringUTF(base.data()); + auto room_j = env->NewStringUTF(room.data()); + auto pk_jbytes = util::bytes_from_ustring(env, pk); + + jobject triple = env->NewObject(clazz, constructor, base_j, room_j, pk_jbytes); + return triple; +} +extern "C" +JNIEXPORT jstring JNICALL +Java_network_loki_messenger_libsession_1util_util_BaseCommunityInfo_fullUrl(JNIEnv *env, + jobject thiz) { + auto deserialized = util::deserialize_base_community(env, thiz); + auto full_url = deserialized.full_url(); + return env->NewStringUTF(full_url.data()); +} \ No newline at end of file diff --git a/libsession-util/src/main/cpp/util.h b/libsession-util/src/main/cpp/util.h new file mode 100644 index 0000000000..0d5189b9c9 --- /dev/null +++ b/libsession-util/src/main/cpp/util.h @@ -0,0 +1,25 @@ +#ifndef SESSION_ANDROID_UTIL_H +#define SESSION_ANDROID_UTIL_H + +#include <jni.h> +#include <array> +#include <optional> +#include "session/types.hpp" +#include "session/config/profile_pic.hpp" +#include "session/config/user_groups.hpp" +#include "session/config/expiring.hpp" + +namespace util { + extern std::mutex util_mutex_; + jbyteArray bytes_from_ustring(JNIEnv* env, session::ustring_view from_str); + session::ustring ustring_from_bytes(JNIEnv* env, jbyteArray byteArray); + jobject serialize_user_pic(JNIEnv *env, session::config::profile_pic pic); + std::pair<jstring, jbyteArray> deserialize_user_pic(JNIEnv *env, jobject user_pic); + jobject serialize_base_community(JNIEnv *env, const session::config::community& base_community); + 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 new file mode 100644 index 0000000000..befd0d6d43 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/Config.kt @@ -0,0 +1,207 @@ +package network.loki.messenger.libsession_util + +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) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun kindFor(configNamespace: Int): Class<ConfigBase> + + fun ConfigBase.protoKindFor(): Kind = when (this) { + is UserProfile -> Kind.USER_PROFILE + is Contacts -> Kind.CONTACTS + is ConversationVolatileConfig -> Kind.CONVO_INFO_VOLATILE + is UserGroupsConfig -> Kind.GROUPS + } + + // TODO: time in future to activate (hardcoded to 1st jan 2024 for testing, change before release) + private const val ACTIVATE_TIME = 1690761600000 + + fun isNewConfigEnabled(forced: Boolean, currentTime: Long) = + forced || currentTime >= ACTIVATE_TIME + + const val PRIORITY_HIDDEN = -1 + const val PRIORITY_VISIBLE = 0 + const val PRIORITY_PINNED = 1 + + } + + external fun dirty(): Boolean + external fun needsPush(): Boolean + external fun needsDump(): Boolean + external fun push(): ConfigPush + external fun dump(): ByteArray + external fun encryptionDomain(): String + external fun confirmPushed(seqNo: Long, newHash: String) + 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>): Stack<String> + + external fun free() + +} + +class Contacts(pointer: Long) : ConfigBase(pointer) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun newInstance(ed25519SecretKey: ByteArray): Contacts + external fun newInstance(ed25519SecretKey: ByteArray, initialDump: ByteArray): Contacts + } + + external fun get(sessionId: String): Contact? + external fun getOrConstruct(sessionId: String): Contact + external fun all(): List<Contact> + external fun set(contact: Contact) + external fun erase(sessionId: String): Boolean + + /** + * Similar to [updateIfExists], but will create the underlying contact if it doesn't exist before passing to [updateFunction] + */ + fun upsertContact(sessionId: String, updateFunction: Contact.()->Unit = {}) { + if (sessionId.startsWith(IdPrefix.BLINDED.value)) { + Log.w("Loki", "Trying to create a contact with a blinded ID prefix") + return + } else if (sessionId.startsWith(IdPrefix.UN_BLINDED.value)) { + Log.w("Loki", "Trying to create a contact with an un-blinded ID prefix") + return + } else if (sessionId.startsWith(IdPrefix.BLINDEDV2.value)) { + Log.w("Loki", "Trying to create a contact with a blindedv2 ID prefix") + return + } + val contact = getOrConstruct(sessionId) + updateFunction(contact) + set(contact) + } + + /** + * Updates the contact by sessionId with a given [updateFunction], and applies to the underlying config. + * the [updateFunction] doesn't run if there is no contact + */ + fun updateIfExists(sessionId: String, updateFunction: Contact.()->Unit) { + if (sessionId.startsWith(IdPrefix.BLINDED.value)) { + Log.w("Loki", "Trying to create a contact with a blinded ID prefix") + return + } else if (sessionId.startsWith(IdPrefix.UN_BLINDED.value)) { + Log.w("Loki", "Trying to create a contact with an un-blinded ID prefix") + return + } else if (sessionId.startsWith(IdPrefix.BLINDEDV2.value)) { + Log.w("Loki", "Trying to create a contact with a blindedv2 ID prefix") + return + } + val contact = get(sessionId) ?: return + updateFunction(contact) + set(contact) + } +} + +class UserProfile(pointer: Long) : ConfigBase(pointer) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun newInstance(ed25519SecretKey: ByteArray): UserProfile + external fun newInstance(ed25519SecretKey: ByteArray, initialDump: ByteArray): UserProfile + } + + external fun setName(newName: String) + external fun getName(): String? + external fun getPic(): UserPic + 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) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun newInstance(ed25519SecretKey: ByteArray): ConversationVolatileConfig + external fun newInstance(ed25519SecretKey: ByteArray, initialDump: ByteArray): ConversationVolatileConfig + } + + external fun getOneToOne(pubKeyHex: String): Conversation.OneToOne? + external fun getOrConstructOneToOne(pubKeyHex: String): Conversation.OneToOne + external fun eraseOneToOne(pubKeyHex: String): Boolean + + external fun getCommunity(baseUrl: String, room: String): Conversation.Community? + external fun getOrConstructCommunity(baseUrl: String, room: String, pubKeyHex: String): Conversation.Community + external fun getOrConstructCommunity(baseUrl: String, room: String, pubKey: ByteArray): Conversation.Community + external fun eraseCommunity(community: Conversation.Community): Boolean + external fun eraseCommunity(baseUrl: String, room: String): Boolean + + external fun getLegacyClosedGroup(groupId: String): Conversation.LegacyGroup? + external fun getOrConstructLegacyGroup(groupId: String): Conversation.LegacyGroup + external fun eraseLegacyClosedGroup(groupId: String): Boolean + external fun erase(conversation: Conversation): Boolean + + external fun set(toStore: Conversation) + + /** + * Erase all conversations that do not satisfy the `predicate`, similar to [MutableList.removeAll] + */ + external fun eraseAll(predicate: (Conversation) -> Boolean): Int + + external fun sizeOneToOnes(): Int + external fun sizeCommunities(): Int + external fun sizeLegacyClosedGroups(): Int + external fun size(): Int + + external fun empty(): Boolean + + external fun allOneToOnes(): List<Conversation.OneToOne> + external fun allCommunities(): List<Conversation.Community> + external fun allLegacyClosedGroups(): List<Conversation.LegacyGroup> + external fun all(): List<Conversation> + +} + +class UserGroupsConfig(pointer: Long): ConfigBase(pointer) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun newInstance(ed25519SecretKey: ByteArray): UserGroupsConfig + external fun newInstance(ed25519SecretKey: ByteArray, initialDump: ByteArray): UserGroupsConfig + } + + external fun getCommunityInfo(baseUrl: String, room: String): GroupInfo.CommunityGroupInfo? + external fun getLegacyGroupInfo(sessionId: String): GroupInfo.LegacyGroupInfo? + external fun getOrConstructCommunityInfo(baseUrl: String, room: String, pubKeyHex: String): GroupInfo.CommunityGroupInfo + external fun getOrConstructLegacyGroupInfo(sessionId: String): GroupInfo.LegacyGroupInfo + external fun set(groupInfo: GroupInfo) + external fun erase(communityInfo: GroupInfo) + external fun eraseCommunity(baseCommunityInfo: BaseCommunityInfo): Boolean + external fun eraseCommunity(server: String, room: String): Boolean + external fun eraseLegacyGroup(sessionId: String): Boolean + external fun sizeCommunityInfo(): Int + external fun sizeLegacyGroupInfo(): Int + external fun size(): Int + external fun all(): List<GroupInfo> + external fun allCommunityInfo(): List<GroupInfo.CommunityGroupInfo> + external fun allLegacyGroupInfo(): List<GroupInfo.LegacyGroupInfo> +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BaseCommunity.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BaseCommunity.kt new file mode 100644 index 0000000000..a48d082a62 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BaseCommunity.kt @@ -0,0 +1,11 @@ +package network.loki.messenger.libsession_util.util + +data class BaseCommunityInfo(val baseUrl: String, val room: String, val pubKeyHex: String) { + companion object { + init { + System.loadLibrary("session_util") + } + external fun parseFullUrl(fullUrl: String): Triple<String, String, ByteArray>? + } + external fun fullUrl(): String +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Contact.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Contact.kt new file mode 100644 index 0000000000..8cc22a6afe --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Contact.kt @@ -0,0 +1,13 @@ +package network.loki.messenger.libsession_util.util + +data class Contact( + val id: String, + var name: String = "", + var nickname: String = "", + var approved: Boolean = false, + var approvedMe: Boolean = false, + var blocked: Boolean = false, + var profilePicture: UserPic = UserPic.DEFAULT, + var priority: Int = 0, + var expiryMode: ExpiryMode, +) \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Conversation.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Conversation.kt new file mode 100644 index 0000000000..97930e8b40 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Conversation.kt @@ -0,0 +1,25 @@ +package network.loki.messenger.libsession_util.util + +sealed class Conversation { + + abstract var lastRead: Long + abstract var unread: Boolean + + data class OneToOne( + val sessionId: String, + override var lastRead: Long, + override var unread: Boolean + ): Conversation() + + data class Community( + val baseCommunityInfo: BaseCommunityInfo, + override var lastRead: Long, + override var unread: Boolean + ) : Conversation() + + data class LegacyGroup( + val groupId: String, + override var lastRead: Long, + override var unread: Boolean + ): Conversation() +} \ No newline at end of file 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 new file mode 100644 index 0000000000..9761ce5083 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/ExpiryMode.kt @@ -0,0 +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) + 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-util/src/main/java/network/loki/messenger/libsession_util/util/GroupInfo.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/GroupInfo.kt new file mode 100644 index 0000000000..c8ace0a9a7 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/GroupInfo.kt @@ -0,0 +1,53 @@ +package network.loki.messenger.libsession_util.util + +sealed class GroupInfo { + + data class CommunityGroupInfo(val community: BaseCommunityInfo, val priority: Int) : GroupInfo() + + data class LegacyGroupInfo( + val sessionId: String, + val name: String, + val members: Map<String, Boolean>, + val encPubKey: ByteArray, + val encSecKey: ByteArray, + val priority: Int, + val disappearingTimer: Long, + val joinedAt: Long + ): GroupInfo() { + companion object { + @Suppress("FunctionName") + external fun NAME_MAX_LENGTH(): Int + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LegacyGroupInfo + + if (sessionId != other.sessionId) return false + if (name != other.name) return false + if (members != other.members) return false + if (!encPubKey.contentEquals(other.encPubKey)) return false + if (!encSecKey.contentEquals(other.encSecKey)) return false + if (priority != other.priority) return false + if (disappearingTimer != other.disappearingTimer) return false + if (joinedAt != other.joinedAt) return false + + return true + } + + override fun hashCode(): Int { + var result = sessionId.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + members.hashCode() + result = 31 * result + encPubKey.contentHashCode() + result = 31 * result + encSecKey.contentHashCode() + result = 31 * result + priority + result = 31 * result + disappearingTimer.hashCode() + result = 31 * result + joinedAt.hashCode() + return result + } + } + +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Sodium.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Sodium.kt new file mode 100644 index 0000000000..6168bd2165 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Sodium.kt @@ -0,0 +1,9 @@ +package network.loki.messenger.libsession_util.util + +object Sodium { + init { + System.loadLibrary("session_util") + } + external fun ed25519KeyPair(seed: ByteArray): KeyPair + external fun ed25519PkToCurve25519(pk: ByteArray): ByteArray +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Utils.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Utils.kt new file mode 100644 index 0000000000..4222395b5d --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/Utils.kt @@ -0,0 +1,67 @@ +package network.loki.messenger.libsession_util.util + +data class ConfigPush(val config: ByteArray, val seqNo: Long, val obsoleteHashes: List<String>) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ConfigPush + + if (!config.contentEquals(other.config)) return false + if (seqNo != other.seqNo) return false + if (obsoleteHashes != other.obsoleteHashes) return false + + return true + } + + override fun hashCode(): Int { + var result = config.contentHashCode() + result = 31 * result + seqNo.hashCode() + result = 31 * result + obsoleteHashes.hashCode() + return result + } + +} + +data class UserPic(val url: String, val key: ByteArray) { + companion object { + val DEFAULT = UserPic("", byteArrayOf()) + } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UserPic + + if (url != other.url) return false + if (!key.contentEquals(other.key)) return false + + return true + } + + override fun hashCode(): Int { + var result = url.hashCode() + result = 31 * result + key.contentHashCode() + return result + } +} + +data class KeyPair(val pubKey: ByteArray, val secretKey: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as KeyPair + + if (!pubKey.contentEquals(other.pubKey)) return false + if (!secretKey.contentEquals(other.secretKey)) return false + + return true + } + + override fun hashCode(): Int { + var result = pubKey.contentHashCode() + result = 31 * result + secretKey.contentHashCode() + return result + } +} \ No newline at end of file diff --git a/libsession-util/src/test/java/network/loki/messenger/libsession_util/ExampleUnitTest.kt b/libsession-util/src/test/java/network/loki/messenger/libsession_util/ExampleUnitTest.kt new file mode 100644 index 0000000000..3d156bfd4d --- /dev/null +++ b/libsession-util/src/test/java/network/loki/messenger/libsession_util/ExampleUnitTest.kt @@ -0,0 +1,14 @@ +package network.loki.messenger.libsession_util + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + +} \ No newline at end of file diff --git a/libsession/build.gradle b/libsession/build.gradle index 0515b3562c..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 { @@ -18,40 +19,36 @@ android { dependencies { implementation project(":libsignal") + implementation project(":libsession-util") implementation project(":liblazysodium") -// implementation 'com.goterl:lazysodium-android:5.0.2@aar' - 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:1.3.2' - implementation 'androidx.appcompat:appcompat:1.2.0' - implementation 'androidx.preference:preference-ktx:1.1.1' - implementation 'com.google.android.material:material:1.2.1' + implementation "androidx.core:core-ktx:$coreVersion" + implementation "androidx.appcompat:appcompat:$appcompatVersion" + implementation "androidx.preference:preference-ktx:$preferenceVersion" + 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.2' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.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' implementation 'com.makeramen:roundedimageview:2.1.0' implementation 'com.esotericsoftware:kryo:5.1.1' - implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" 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:4.12' + 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 'org.powermock:powermock-api-mockito:1.6.1' - testImplementation 'org.powermock:powermock-module-junit4:1.6.1' - testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.1' - testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1' - testImplementation 'androidx.test:core:1.3.0' + testImplementation "androidx.test:core:$testCoreVersion" testImplementation "androidx.arch.core:core-testing:2.1.0" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" testImplementation "org.conscrypt:conscrypt-openjdk-uber:2.0.0" diff --git a/libsession/src/debug/res/values/values.xml b/libsession/src/debug/res/values/values.xml new file mode 100644 index 0000000000..207edfc843 --- /dev/null +++ b/libsession/src/debug/res/values/values.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <bool name="screen_security_default">false</bool> +</resources> 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 2bcd32ac24..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,11 +8,11 @@ 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; - import org.session.libsession.R; import org.session.libsession.utilities.ThemeUtil; @@ -32,16 +32,18 @@ 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); + foreground.setScaleType(ImageView.ScaleType.CENTER_CROP); if (inverted) { 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 eb40df6e09..52c0b8c7a3 100644 --- a/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt +++ b/libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt @@ -20,9 +20,11 @@ interface MessageDataProvider { * @return pair of sms or mms table-specific ID and whether it is in SMS table */ fun getMessageID(serverId: Long, threadId: Long): Pair<Long, Boolean>? + fun getMessageIDs(serverIDs: List<Long>, threadID: Long): Pair<List<Long>, List<Long>> fun deleteMessage(messageID: Long, isSms: Boolean) - fun updateMessageAsDeleted(timestamp: Long, author: String) - fun getServerHashForMessage(messageID: Long): String? + fun deleteMessages(messageIDs: List<Long>, threadId: Long, isSms: Boolean) + fun updateMessageAsDeleted(timestamp: Long, author: String): Long? + fun getServerHashForMessage(messageID: Long, mms: Boolean): String? fun getDatabaseAttachment(attachmentId: Long): DatabaseAttachment? fun getAttachmentStream(attachmentId: Long): SessionServiceAttachmentStream? fun getAttachmentPointer(attachmentId: Long): SessionServiceAttachmentPointer? @@ -36,7 +38,7 @@ interface MessageDataProvider { fun isOutgoingMessage(timestamp: Long): Boolean fun handleSuccessfulAttachmentUpload(attachmentId: Long, attachmentStream: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) fun handleFailedAttachmentUpload(attachmentId: Long) - fun getMessageForQuote(timestamp: Long, author: Address): Pair<Long, Boolean>? + fun getMessageForQuote(timestamp: Long, author: Address): Triple<Long, Boolean, String>? fun getAttachmentsAndLinkPreviewFor(mmsId: Long): List<Attachment> fun getMessageBodyFor(timestamp: Long, author: String): String fun getAttachmentIDsFor(messageID: Long): List<Long> 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 f35e5de0d4..260e254fe7 100644 --- a/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/libsession/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -2,20 +2,25 @@ package org.session.libsession.database import android.content.Context import android.net.Uri +import network.loki.messenger.libsession_util.ConfigBase import org.session.libsession.messaging.BlindedIdMapping import org.session.libsession.messaging.calls.CallMessageType import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.jobs.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 import org.session.libsession.messaging.messages.visible.Attachment +import org.session.libsession.messaging.messages.visible.Profile import org.session.libsession.messaging.messages.visible.Reaction import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.GroupMember import org.session.libsession.messaging.open_groups.OpenGroup +import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage @@ -28,16 +33,19 @@ import org.session.libsession.utilities.recipients.Recipient.RecipientSettings import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceAttachmentPointer import org.session.libsignal.messages.SignalServiceGroup +import network.loki.messenger.libsession_util.util.Contact as LibSessionContact interface StorageProtocol { // General fun getUserPublicKey(): String? fun getUserX25519KeyPair(): ECKeyPair - fun getUserDisplayName(): String? - fun getUserProfileKey(): ByteArray? - fun getUserProfilePictureURL(): String? - fun setUserProfilePictureURL(newProfilePicture: String) + 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 fun getOrGenerateRegistrationID(): Int @@ -49,9 +57,11 @@ interface StorageProtocol { fun getAttachmentUploadJob(attachmentID: Long): AttachmentUploadJob? fun getMessageSendJob(messageSendJobID: String): MessageSendJob? fun getMessageReceiveJob(messageReceiveJobID: String): Job? - fun getGroupAvatarDownloadJob(server: String, room: String): Job? + fun getGroupAvatarDownloadJob(server: String, room: String, imageId: String?): Job? + fun getConfigSyncJob(destination: Destination): Job? fun resumeMessageSendJobIfNeeded(messageSendJobID: String) fun isJobCanceled(job: Job): Boolean + fun cancelPendingMessageSendJobs(threadID: Long) // Authorization fun getAuthToken(room: String, server: String): String? @@ -66,8 +76,8 @@ interface StorageProtocol { fun getAllOpenGroups(): Map<Long, OpenGroup> fun updateOpenGroup(openGroup: OpenGroup) fun getOpenGroup(threadId: Long): OpenGroup? - fun addOpenGroup(urlAsString: String) - fun onOpenGroupAdded(server: String) + fun addOpenGroup(urlAsString: String): OpenGroupApi.RoomInfo? + fun onOpenGroupAdded(server: String, room: String) fun hasBackgroundGroupAddJob(groupJoinUrl: String): Boolean fun setOpenGroupServerMessageID(messageID: Long, serverID: Long, threadID: Long, isSms: Boolean) fun getOpenGroup(room: String, server: String): OpenGroup? @@ -80,6 +90,8 @@ interface StorageProtocol { // Open Group Metadata fun updateTitle(groupID: String, newValue: String) fun updateProfilePicture(groupID: String, newValue: ByteArray) + fun removeProfilePicture(groupID: String) + fun hasDownloadedProfilePicture(groupID: String): Boolean fun setUserCount(room: String, server: String, newValue: Int) // Last Message Server ID @@ -102,17 +114,25 @@ 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 setErrorMessage(timestamp: Long, author: String, error: Exception) - fun setMessageServerHash(messageID: Long, serverHash: 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, 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, expirationTimer: Int) + fun updateGroupConfig(groupPublicKey: String) fun isGroupActive(groupPublicKey: String): Boolean fun setActive(groupID: String, value: Boolean) fun getZombieMembers(groupID: String): Set<String> @@ -123,7 +143,7 @@ interface StorageProtocol { fun getAllActiveClosedGroupPublicKeys(): Set<String> fun addClosedGroupPublicKey(groupPublicKey: String) fun removeClosedGroupPublicKey(groupPublicKey: String) - fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String) + fun addClosedGroupEncryptionKeyPair(encryptionKeyPair: ECKeyPair, groupPublicKey: String, timestamp: Long) fun removeAllClosedGroupEncryptionKeyPairs(groupPublicKey: String) fun insertIncomingInfoMessage(context: Context, senderPublicKey: String, groupID: String, type: SignalServiceGroup.Type, name: String, members: Collection<String>, admins: Collection<String>, sentTimestamp: Long) @@ -134,18 +154,18 @@ interface StorageProtocol { fun getLatestClosedGroupEncryptionKeyPair(groupPublicKey: String): ECKeyPair? fun updateFormationTimestamp(groupID: String, formationTimestamp: Long) fun updateTimestampUpdated(groupID: String, updatedTimestamp: Long) - fun setExpirationTimer(groupID: String, duration: Int) // Groups - fun getAllGroups(): List<GroupRecord> + fun getAllGroups(includeInactive: Boolean): List<GroupRecord> // Settings fun setProfileSharing(address: Address, value: Boolean) // Thread fun getOrCreateThreadIdFor(address: Address): Long - fun getOrCreateThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?): Long + fun getThreadIdFor(publicKey: String, groupPublicKey: String?, openGroupID: String?, createThread: Boolean): Long? fun getThreadId(publicKeyOrOpenGroupID: String): Long? + fun getThreadId(openGroup: OpenGroup): Long? fun getThreadId(address: Address): Long? fun getThreadId(recipient: Recipient): Long? fun getThreadIdForMms(mmsId: Long): Long @@ -153,6 +173,12 @@ interface StorageProtocol { fun trimThread(threadID: Long, threadLimit: Int) fun trimThreadBefore(threadID: Long, timestamp: Long) fun getMessageCount(threadID: Long): Long + fun setPinned(threadID: Long, isPinned: Boolean) + 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? @@ -160,6 +186,7 @@ interface StorageProtocol { fun setContact(contact: Contact) fun getRecipientForThread(threadId: Long): Recipient? fun getRecipientSettings(address: Address): RecipientSettings? + fun addLibSessionContacts(contacts: List<LibSessionContact>, timestamp: Long) fun addContacts(contacts: List<ConfigurationMessage.Contact>) // Attachments @@ -170,13 +197,14 @@ interface StorageProtocol { /** * Returns the ID of the `TSIncomingMessage` that was constructed. */ - fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List<LinkPreview?>, groupPublicKey: String?, openGroupID: String?, attachments: List<Attachment>, runIncrement: Boolean, runThreadUpdate: Boolean): Long? - fun markConversationAsRead(threadId: Long, updateLastSeen: Boolean) - fun incrementUnread(threadId: Long, amount: Int) + fun persist(message: VisibleMessage, quotes: QuoteModel?, linkPreview: List<LinkPreview?>, groupPublicKey: String?, openGroupID: String?, attachments: List<Attachment>, runThreadUpdate: Boolean): Long? + fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean = false) + fun getLastSeen(threadId: Long): Long fun updateThread(threadId: Long, unarchive: Boolean) fun insertDataExtractionNotificationMessage(senderPublicKey: String, message: DataExtractionNotificationInfoMessage, sentTimestamp: Long) fun insertMessageRequestResponse(response: MessageRequestResponse) fun setRecipientApproved(recipient: Recipient, approved: Boolean) + fun getRecipientApproved(address: Address): Boolean fun setRecipientApprovedMe(recipient: Recipient, approvedMe: Boolean) fun insertCallMessage(senderPublicKey: String, callMessageType: CallMessageType, sentTimestamp: Long) fun conversationHasOutgoing(userPublicKey: String): Boolean @@ -196,6 +224,21 @@ interface StorageProtocol { fun removeReaction(emoji: String, messageTimestamp: Long, author: String, notifyUnread: Boolean) fun updateReactionIfNeeded(message: Message, sender: String, openGroupSentTimestamp: Long) fun deleteReactions(messageId: Long, mms: Boolean) - fun unblock(toUnblock: List<Recipient>) + 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, 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 37c391dfd6..e4f15b2114 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -4,12 +4,17 @@ import android.content.Context 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 getUserED25519KeyPair: () -> KeyPair?, + 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/file_server/FileServerApi.kt b/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt index b2ca605d2b..0e8768d530 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt @@ -16,15 +16,6 @@ object FileServerApi { private const val serverPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" const val server = "http://filev2.getsession.org" const val maxFileSize = 10_000_000 // 10 MB - /** - * The file server has a file size limit of `maxFileSize`, which the Service Nodes try to enforce as well. However, the limit applied by the Service Nodes - * is on the **HTTP request** and not the actual file size. Because the file server expects the file data to be base 64 encoded, the size of the HTTP - * request for a given file will be at least `ceil(n / 3) * 4` bytes, where n is the file size in bytes. This is the minimum size because there might also - * be other parameters in the request. On average the multiplier appears to be about 1.5, so when checking whether the file will exceed the file size limit when - * uploading a file we just divide the size of the file by this number. The alternative would be to actually check the size of the HTTP request but that's only - * possible after proof of work has been calculated and the onion request encryption has happened, which takes several seconds. - */ - const val fileSizeORMultiplier = 2 // TODO: It should be possible to set this to 1.5? sealed class Error(message: String) : Exception(message) { object ParsingFailed : Error("Invalid response.") @@ -77,7 +68,11 @@ object FileServerApi { OnionRequestAPI.sendOnionRequest(requestBuilder.build(), server, serverPublicKey).map { it.body ?: throw Error.ParsingFailed }.fail { e -> - Log.e("Loki", "File server request failed.", e) + when (e) { + // No need for the stack trace for HTTP errors + is HTTP.HTTPRequestFailedException -> Log.e("Loki", "File server request failed due to error: ${e.message}") + else -> Log.e("Loki", "File server request failed", e) + } } } else { Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests.")) @@ -96,7 +91,10 @@ object FileServerApi { ) return send(request).map { response -> val json = JsonUtil.fromJson(response, Map::class.java) - (json["id"] as? String)?.toLong() ?: throw Error.ParsingFailed + val hasId = json.containsKey("id") + val id = json.getOrDefault("id", null) + Log.d("Loki-FS", "File Upload Response hasId: $hasId of type: ${id?.javaClass}") + (id as? String)?.toLong() ?: throw Error.ParsingFailed } } 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 826df3ef8d..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,14 +35,14 @@ 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" private val TS_INCOMING_MESSAGE_ID_KEY = "tsIncoming_message_id" } - override fun execute() { + override suspend fun execute(dispatcherName: String) { val storage = MessagingModuleConfiguration.shared.storage val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val threadID = storage.getThreadIdForMms(databaseMessageID) @@ -59,7 +59,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) Log.d("AttachmentDownloadJob", "Setting attachment state = failed, don't have attachment") messageDataProvider.setAttachmentState(AttachmentState.FAILED, AttachmentId(attachmentID,0), databaseMessageID) } - this.handlePermanentFailure(exception) + this.handlePermanentFailure(dispatcherName, exception) } else if (exception == Error.DuplicateData) { attachment?.let { id -> Log.d("AttachmentDownloadJob", "Setting attachment state = done from duplicate data") @@ -68,7 +68,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) Log.d("AttachmentDownloadJob", "Setting attachment state = done from duplicate data") messageDataProvider.setAttachmentState(AttachmentState.DONE, AttachmentId(attachmentID,0), databaseMessageID) } - this.handleSuccess() + this.handleSuccess(dispatcherName) } else { if (failureCount + 1 >= maxFailureCount) { attachment?.let { id -> @@ -79,7 +79,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) messageDataProvider.setAttachmentState(AttachmentState.FAILED, AttachmentId(attachmentID,0), databaseMessageID) } } - this.handleFailure(exception) + this.handleFailure(dispatcherName, exception) } } @@ -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 } @@ -150,7 +152,7 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) Log.d("AttachmentDownloadJob", "deleting tempfile") tempFile.delete() Log.d("AttachmentDownloadJob", "succeeding job") - handleSuccess() + handleSuccess(dispatcherName) } catch (e: Exception) { Log.e("AttachmentDownloadJob", "Error processing attachment download", e) tempFile?.delete() @@ -169,17 +171,17 @@ class AttachmentDownloadJob(val attachmentID: Long, val databaseMessageID: Long) } } - private fun handleSuccess() { + private fun handleSuccess(dispatcherName: String) { Log.w("AttachmentDownloadJob", "Attachment downloaded successfully.") - delegate?.handleJobSucceeded(this) + delegate?.handleJobSucceeded(this, dispatcherName) } - private fun handlePermanentFailure(e: Exception) { - delegate?.handleJobFailedPermanently(this, e) + private fun handlePermanentFailure(dispatcherName: String, e: Exception) { + delegate?.handleJobFailedPermanently(this, dispatcherName, e) } - private fun handleFailure(e: Exception) { - delegate?.handleJobFailed(this, e) + private fun handleFailure(dispatcherName: String, e: Exception) { + delegate?.handleJobFailed(this, dispatcherName, e) } private fun createTempFile(): File { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt index 360207af43..19b6555b50 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt @@ -16,7 +16,11 @@ import org.session.libsession.utilities.DecodedAudio import org.session.libsession.utilities.InputStreamMediaDataSource import org.session.libsession.utilities.UploadResult import org.session.libsignal.messages.SignalServiceAttachmentStream -import org.session.libsignal.streams.* +import org.session.libsignal.streams.AttachmentCipherOutputStream +import org.session.libsignal.streams.AttachmentCipherOutputStreamFactory +import org.session.libsignal.streams.DigestingRequestBody +import org.session.libsignal.streams.PaddingInputStream +import org.session.libsignal.streams.PlaintextOutputStreamFactory import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.PushAttachmentData import org.session.libsignal.utilities.Util @@ -45,29 +49,29 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess private val MESSAGE_SEND_JOB_ID_KEY = "message_send_job_id" } - override fun execute() { + override suspend fun execute(dispatcherName: String) { try { val storage = MessagingModuleConfiguration.shared.storage val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val attachment = messageDataProvider.getScaledSignalAttachmentStream(attachmentID) - ?: return handleFailure(Error.NoAttachment) + ?: return handleFailure(dispatcherName, Error.NoAttachment) val openGroup = storage.getOpenGroup(threadID.toLong()) if (openGroup != null) { val keyAndResult = upload(attachment, openGroup.server, false) { OpenGroupApi.upload(it, openGroup.room, openGroup.server) } - handleSuccess(attachment, keyAndResult.first, keyAndResult.second) + handleSuccess(dispatcherName, attachment, keyAndResult.first, keyAndResult.second) } else { val keyAndResult = upload(attachment, FileServerApi.server, true) { FileServerApi.upload(it) } - handleSuccess(attachment, keyAndResult.first, keyAndResult.second) + handleSuccess(dispatcherName, attachment, keyAndResult.first, keyAndResult.second) } } catch (e: java.lang.Exception) { if (e == Error.NoAttachment) { - this.handlePermanentFailure(e) + this.handlePermanentFailure(dispatcherName, e) } else { - this.handleFailure(e) + this.handleFailure(dispatcherName, e) } } } @@ -104,9 +108,9 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess return Pair(key, UploadResult(id, "${server}/file/$id", digest)) } - private fun handleSuccess(attachment: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) { + private fun handleSuccess(dispatcherName: String, attachment: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) { Log.d(TAG, "Attachment uploaded successfully.") - delegate?.handleJobSucceeded(this) + delegate?.handleJobSucceeded(this, dispatcherName) val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider messageDataProvider.handleSuccessfulAttachmentUpload(attachmentID, attachment, attachmentKey, uploadResult) if (attachment.contentType.startsWith("audio/")) { @@ -144,16 +148,16 @@ class AttachmentUploadJob(val attachmentID: Long, val threadID: String, val mess storage.resumeMessageSendJobIfNeeded(messageSendJobID) } - private fun handlePermanentFailure(e: Exception) { + private fun handlePermanentFailure(dispatcherName: String, e: Exception) { Log.w(TAG, "Attachment upload failed permanently due to error: $this.") - delegate?.handleJobFailedPermanently(this, e) + delegate?.handleJobFailedPermanently(this, dispatcherName, e) MessagingModuleConfiguration.shared.messageDataProvider.handleFailedAttachmentUpload(attachmentID) failAssociatedMessageSendJob(e) } - private fun handleFailure(e: Exception) { + private fun handleFailure(dispatcherName: String, e: Exception) { Log.w(TAG, "Attachment upload failed due to error: $this.") - delegate?.handleJobFailed(this, e) + delegate?.handleJobFailed(this, dispatcherName, e) if (failureCount + 1 >= maxFailureCount) { failAssociatedMessageSendJob(e) } 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 288ae75f82..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 @@ -3,9 +3,7 @@ package org.session.libsession.messaging.jobs import okhttp3.HttpUrl import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.open_groups.OpenGroup -import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.utilities.Data -import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.OpenGroupUrlParser import org.session.libsignal.utilities.Log @@ -29,37 +27,26 @@ class BackgroundGroupAddJob(val joinUrl: String): Job { return "$server.$room" } - override fun execute() { + override suspend fun execute(dispatcherName: String) { try { val openGroup = OpenGroupUrlParser.parseUrl(joinUrl) val storage = MessagingModuleConfiguration.shared.storage val allOpenGroups = storage.getAllOpenGroups().map { it.value.joinURL } if (allOpenGroups.contains(openGroup.joinUrl())) { Log.e("OpenGroupDispatcher", "Failed to add group because", DuplicateGroupException()) - delegate?.handleJobFailed(this, DuplicateGroupException()) + delegate?.handleJobFailed(this, dispatcherName, DuplicateGroupException()) return } - // get image - storage.setOpenGroupPublicKey(openGroup.server, openGroup.serverPublicKey) - val (capabilities, info) = OpenGroupApi.getCapabilitiesAndRoomInfo(openGroup.room, openGroup.server).get() - storage.setServerCapabilities(openGroup.server, capabilities.capabilities) - val imageId = info.imageId + storage.addOpenGroup(openGroup.joinUrl()) - if (imageId != null) { - val bytes = OpenGroupApi.downloadOpenGroupProfilePicture(openGroup.server, openGroup.room, imageId).get() - val groupId = GroupUtil.getEncodedOpenGroupID("${openGroup.server}.${openGroup.room}".toByteArray()) - storage.updateProfilePicture(groupId, bytes) - storage.updateTimestampUpdated(groupId, System.currentTimeMillis()) - } - Log.d(KEY, "onOpenGroupAdded(${openGroup.server})") - storage.onOpenGroupAdded(openGroup.server) + storage.onOpenGroupAdded(openGroup.server, openGroup.room) } catch (e: Exception) { Log.e("OpenGroupDispatcher", "Failed to add group because",e) - delegate?.handleJobFailed(this, e) + delegate?.handleJobFailed(this, dispatcherName, e) return } Log.d("Loki", "Group added successfully") - delegate?.handleJobSucceeded(this) + delegate?.handleJobSucceeded(this, dispatcherName) } override fun serialize(): Data = Data.Builder() 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 07c104cfda..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 @@ -7,16 +7,25 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import nl.komponents.kovenant.Promise import nl.komponents.kovenant.task -import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.control.CallMessage +import org.session.libsession.messaging.messages.control.ClosedGroupControlMessage +import org.session.libsession.messaging.messages.control.ConfigurationMessage +import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.messages.control.SharedConfigurationMessage +import org.session.libsession.messaging.messages.control.TypingIndicator +import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.ParsedMessage import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageReceiver import org.session.libsession.messaging.sending_receiving.handle import org.session.libsession.messaging.sending_receiving.handleOpenGroupReactions +import org.session.libsession.messaging.sending_receiving.handleUnsendRequest import org.session.libsession.messaging.sending_receiving.handleVisibleMessage import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.SessionId @@ -25,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, @@ -51,6 +61,9 @@ class BatchMessageReceiveJob( const val BATCH_DEFAULT_NUMBER = 512 + // used for processing messages that don't have a thread and shouldn't create one + const val NO_THREAD_MAPPING = -1L + // Keys used for database storage private val NUM_MESSAGES_KEY = "numMessages" private val DATA_KEY = "data" @@ -59,116 +72,182 @@ class BatchMessageReceiveJob( private val OPEN_GROUP_ID_KEY = "open_group_id" } - private fun getThreadId(message: Message, storage: StorageProtocol): Long { - val senderOrSync = when (message) { - is VisibleMessage -> message.syncTarget ?: message.sender!! - is ExpirationTimerUpdate -> message.syncTarget ?: message.sender!! - else -> message.sender!! + private fun shouldCreateThread(parsedMessage: ParsedMessage): Boolean { + val message = parsedMessage.message + if (message is VisibleMessage) return true + else { // message is control message otherwise + return when(message) { + is SharedConfigurationMessage -> false + is ClosedGroupControlMessage -> false // message.kind is ClosedGroupControlMessage.Kind.New && !message.isSenderSelf + is DataExtractionNotification -> false + is MessageRequestResponse -> false + is ExpirationTimerUpdate -> false + is ConfigurationMessage -> false + is TypingIndicator -> false + is UnsendRequest -> false + is ReadReceipt -> false + is CallMessage -> false // TODO: maybe + else -> false // shouldn't happen, or I guess would be Visible + } } - return storage.getOrCreateThreadIdFor(senderOrSync, message.groupPublicKey, openGroupID) } - override fun execute() { - executeAsync().get() + override suspend fun execute(dispatcherName: String) { + executeAsync(dispatcherName).get() } - fun executeAsync(): Promise<Unit, Exception> { + fun executeAsync(dispatcherName: String): Promise<Unit, Exception> { return task { val threadMap = mutableMapOf<Long, MutableList<ParsedMessage>>() val storage = MessagingModuleConfiguration.shared.storage val context = MessagingModuleConfiguration.shared.context val localUserPublicKey = storage.getUserPublicKey() val serverPublicKey = openGroupID?.let { storage.getOpenGroupPublicKey(it.split(".").dropLast(1).joinToString(".")) } + val currentClosedGroups = storage.getAllActiveClosedGroupPublicKeys() // parse and collect IDs messages.forEach { messageParameters -> val (data, serverHash, openGroupMessageServerID) = messageParameters try { - val (message, proto) = MessageReceiver.parse(data, openGroupMessageServerID, openGroupPublicKey = serverPublicKey) + val (message, proto) = MessageReceiver.parse(data, openGroupMessageServerID, openGroupPublicKey = serverPublicKey, currentClosedGroups = currentClosedGroups) message.serverHash = serverHash - val threadID = getThreadId(message, storage) val parsedParams = ParsedMessage(messageParameters, message, proto) + val threadID = Message.getThreadId(message, openGroupID, storage, shouldCreateThread(parsedParams)) ?: NO_THREAD_MAPPING if (!threadMap.containsKey(threadID)) { threadMap[threadID] = mutableListOf(parsedParams) } else { threadMap[threadID]!! += parsedParams } } catch (e: Exception) { - Log.e(TAG, "Couldn't receive message.", e) - if (e is MessageReceiver.Error && !e.isRetryable) { - Log.e(TAG, "Message failed permanently",e) - } else { - Log.e(TAG, "Message failed",e) - failures += messageParameters + when (e) { + is MessageReceiver.Error.DuplicateMessage, MessageReceiver.Error.SelfSend -> { + Log.i(TAG, "Couldn't receive message, failed with error: ${e.message} (id: $id)") + } + is MessageReceiver.Error -> { + if (!e.isRetryable) { + Log.e(TAG, "Couldn't receive message, failed permanently (id: $id)", e) + } + else { + Log.e(TAG, "Couldn't receive message, failed (id: $id)", e) + failures += messageParameters + } + } + else -> { + Log.e(TAG, "Couldn't receive message, failed (id: $id)", e) + failures += messageParameters + } } } } // iterate over threads and persist them (persistence is the longest constant in the batch process operation) runBlocking(Dispatchers.IO) { - val deferredThreadMap = threadMap.entries.map { (threadId, messages) -> - async { - val messageIds = mutableListOf<Pair<Long, Boolean>>() - messages.forEach { (parameters, message, proto) -> - try { - if (message is VisibleMessage) { + + fun processMessages(threadId: Long, messages: List<ParsedMessage>) = async { + // The LinkedHashMap should preserve insertion order + val messageIds = linkedMapOf<Long, Pair<Boolean, Boolean>>() + val myLastSeen = storage.getLastSeen(threadId) + var newLastSeen = myLastSeen.takeUnless { it == -1L } ?: 0 + messages.forEach { (parameters, message, proto) -> + try { + when (message) { + is VisibleMessage -> { + val isUserBlindedSender = + message.sender == serverPublicKey?.let { + SodiumUtilities.blindedKeyPair( + it, + MessagingModuleConfiguration.shared.getUserED25519KeyPair()!! + ) + }?.let { + SessionId( + IdPrefix.BLINDED, it.publicKey.asBytes + ).hexString + } + if (message.sender == localUserPublicKey || isUserBlindedSender) { + // 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, - runIncrement = false, + threadId, runThreadUpdate = false, - runProfileUpdate = true - ) + runProfileUpdate = true) + if (messageId != null && message.reaction == null) { - val isUserBlindedSender = message.sender == serverPublicKey?.let { SodiumUtilities.blindedKeyPair(it, MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!) }?.let { SessionId( - IdPrefix.BLINDED, it.publicKey.asBytes).hexString } - messageIds += messageId to (message.sender == localUserPublicKey || isUserBlindedSender) + messageIds[messageId] = Pair( + (message.sender == localUserPublicKey || isUserBlindedSender), + message.hasMention + ) } parameters.openGroupMessageServerID?.let { - MessageReceiver.handleOpenGroupReactions(threadId, it, parameters.reactions) + MessageReceiver.handleOpenGroupReactions( + threadId, + it, + parameters.reactions + ) } - } else { - MessageReceiver.handle(message, proto, openGroupID) } - } catch (e: Exception) { - Log.e(TAG, "Couldn't process message.", e) - if (e is MessageReceiver.Error && !e.isRetryable) { - Log.e(TAG, "Message failed permanently",e) - } else { - Log.e(TAG, "Message failed",e) - failures += parameters + + is UnsendRequest -> { + val deletedMessageId = + MessageReceiver.handleUnsendRequest(message) + + // If we removed a message then ensure it isn't in the 'messageIds' + if (deletedMessageId != null) { + messageIds.remove(deletedMessageId) + } } + + else -> MessageReceiver.handle(message, proto, threadId, openGroupID) + } + } catch (e: Exception) { + Log.e(TAG, "Couldn't process message (id: $id)", e) + if (e is MessageReceiver.Error && !e.isRetryable) { + Log.e(TAG, "Message failed permanently (id: $id)", e) + } else { + Log.e(TAG, "Message failed (id: $id)", e) + failures += parameters } } - // increment unreads, notify, and update thread - val unreadFromMine = messageIds.indexOfLast { (_,fromMe) -> fromMe } - var trueUnreadCount = messageIds.filter { (_,fromMe) -> !fromMe }.size - if (unreadFromMine >= 0) { - trueUnreadCount -= (unreadFromMine + 1) - storage.markConversationAsRead(threadId, false) - } - if (trueUnreadCount > 0) { - storage.incrementUnread(threadId, trueUnreadCount) - } - storage.updateThread(threadId, true) - SSKEnvironment.shared.notificationManager.updateNotification(context, threadId) } + // increment unreads, notify, and update thread + // 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 } + newLastSeen = max(newLastSeen, currentLastSeen) + if (newLastSeen > 0 || currentLastSeen == 0L) { + storage.markConversationAsRead(threadId, newLastSeen, force = true) + } + storage.updateThread(threadId, true) + SSKEnvironment.shared.notificationManager.updateNotification(context, threadId) + } + + val withoutDefault = threadMap.entries.filter { it.key != NO_THREAD_MAPPING } + val noThreadMessages = threadMap[NO_THREAD_MAPPING] ?: listOf() + val deferredThreadMap = withoutDefault.map { (threadId, messages) -> + processMessages(threadId, messages) } // await all thread processing deferredThreadMap.awaitAll() + if (noThreadMessages.isNotEmpty()) { + processMessages(NO_THREAD_MAPPING, noThreadMessages).await() + } } if (failures.isEmpty()) { - handleSuccess() + handleSuccess(dispatcherName) } else { - handleFailure() + handleFailure(dispatcherName) } } } - private fun handleSuccess() { - this.delegate?.handleJobSucceeded(this) + private fun handleSuccess(dispatcherName: String) { + Log.i(TAG, "Completed processing of ${messages.size} messages (id: $id)") + delegate?.handleJobSucceeded(this, dispatcherName) } - private fun handleFailure() { - this.delegate?.handleJobFailed(this, Exception("One or more jobs resulted in failure")) + private fun handleFailure(dispatcherName: String) { + Log.i(TAG, "Handling failure of ${failures.size} messages (${messages.size - failures.size} processed successfully) (id: $id)") + delegate?.handleJobFailed(this, dispatcherName, Exception("One or more jobs resulted in failure")) } override fun serialize(): Data { @@ -201,10 +280,9 @@ class BatchMessageReceiveJob( val openGroupID = data.getStringOrDefault(OPEN_GROUP_ID_KEY, null) val parameters = (0 until numMessages).map { index -> - val data = contents[index] val serverHash = serverHashes[index].let { if (it.isEmpty()) null else it } val serverId = openGroupMessageServerIDs[index].let { if (it == -1L) null else it } - MessageReceiveParameters(data, serverHash, serverId) + MessageReceiveParameters(contents[index], serverHash, serverId) } return BatchMessageReceiveJob(parameters, openGroupID) diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt new file mode 100644 index 0000000000..ec8de44163 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt @@ -0,0 +1,206 @@ +package org.session.libsession.messaging.jobs + +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.ConfigBase.Companion.protoKindFor +import nl.komponents.kovenant.functional.bind +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.control.SharedConfigurationMessage +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.utilities.Data +import org.session.libsession.snode.RawResponse +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import java.util.concurrent.atomic.AtomicBoolean + +// only contact (self) and closed group destinations will be supported +data class ConfigurationSyncJob(val destination: Destination): Job { + + override var delegate: JobDelegate? = null + override var id: String? = null + override var failureCount: Int = 0 + override val maxFailureCount: Int = 10 + + val shouldRunAgain = AtomicBoolean(false) + + override suspend fun execute(dispatcherName: String) { + val storage = MessagingModuleConfiguration.shared.storage + val forcedConfig = TextSecurePreferences.hasForcedNewConfig(MessagingModuleConfiguration.shared.context) + val currentTime = SnodeAPI.nowWithOffset + val userEdKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() + val userPublicKey = storage.getUserPublicKey() + val delegate = delegate + if (destination is Destination.ClosedGroup // TODO: closed group configs will be handled in closed group feature + // if we haven't enabled the new configs don't run + || !ConfigBase.isNewConfigEnabled(forcedConfig, currentTime) + // if we don't have a user ed key pair for signing updates + || userEdKeyPair == null + // this will be useful to not handle null delegate cases + || delegate == null + // check our local identity key exists + || userPublicKey.isNullOrEmpty() + // don't allow pushing configs for non-local user + || (destination is Destination.Contact && destination.publicKey != userPublicKey) + ) { + Log.w(TAG, "No need to run config sync job, TODO") + return delegate?.handleJobSucceeded(this, dispatcherName) ?: Unit + } + + // configFactory singleton instance will come in handy for modifying hashes and fetching configs for namespace etc + val configFactory = MessagingModuleConfiguration.shared.configFactory + + // get latest states, filter out configs that don't need push + val configsRequiringPush = configFactory.getUserConfigs().filter { config -> config.needsPush() } + + // don't run anything if we don't need to push anything + if (configsRequiringPush.isEmpty()) return delegate.handleJobSucceeded(this, dispatcherName) + + // need to get the current hashes before we call `push()` + val toDeleteHashes = mutableListOf<String>() + + // allow null results here so the list index matches configsRequiringPush + val sentTimestamp: Long = SnodeAPI.nowWithOffset + val batchObjects: List<Pair<SharedConfigurationMessage, SnodeAPI.SnodeBatchRequestInfo>?> = configsRequiringPush.map { config -> + val (data, seqNo, obsoleteHashes) = config.push() + toDeleteHashes += obsoleteHashes + SharedConfigurationMessage(config.protoKindFor(), data, seqNo) to config + }.map { (message, config) -> + // return a list of batch request objects + val snodeMessage = MessageSender.buildWrappedMessageToSnode(destination, message, true) + val authenticated = SnodeAPI.buildAuthenticatedStoreBatchInfo( + destination.destinationPublicKey(), + config.configNamespace(), + snodeMessage + ) ?: return@map null // this entry will be null otherwise + message to authenticated // to keep track of seqNo for calling confirmPushed later + } + + val toDeleteRequest = toDeleteHashes.let { toDeleteFromAllNamespaces -> + if (toDeleteFromAllNamespaces.isEmpty()) null + else SnodeAPI.buildAuthenticatedDeleteBatchInfo(destination.destinationPublicKey(), toDeleteFromAllNamespaces) + } + + if (batchObjects.any { it == null }) { + // stop running here, something like a signing error occurred + return delegate.handleJobFailedPermanently(this, dispatcherName, NullPointerException("One or more requests had a null batch request info")) + } + + val allRequests = mutableListOf<SnodeAPI.SnodeBatchRequestInfo>() + allRequests += batchObjects.requireNoNulls().map { (_, request) -> request } + // add in the deletion if we have any hashes + if (toDeleteRequest != null) { + allRequests += toDeleteRequest + Log.d(TAG, "Including delete request for current hashes") + } + + val batchResponse = SnodeAPI.getSingleTargetSnode(destination.destinationPublicKey()).bind { snode -> + SnodeAPI.getRawBatchResponse( + snode, + destination.destinationPublicKey(), + allRequests, + sequence = true + ) + } + + try { + val rawResponses = batchResponse.get() + @Suppress("UNCHECKED_CAST") + val responseList = (rawResponses["results"] as List<RawResponse>) + // we are always adding in deletions at the end + val deletionResponse = if (toDeleteRequest != null && responseList.isNotEmpty()) responseList.last() else null + val deletedHashes = deletionResponse?.let { + @Suppress("UNCHECKED_CAST") + // get the sub-request body + (deletionResponse["body"] as? RawResponse)?.let { body -> + // get the swarm dict + body["swarm"] as? RawResponse + }?.mapValues { (_, swarmDict) -> + // get the deleted values from dict + ((swarmDict as? RawResponse)?.get("deleted") as? List<String>)?.toSet() ?: emptySet() + }?.values?.reduce { acc, strings -> + // create an intersection of all deleted hashes (common between all swarm nodes) + acc intersect strings + } + } ?: emptySet() + + // at this point responseList index should line up with configsRequiringPush index + configsRequiringPush.forEachIndexed { index, config -> + val (toPushMessage, _) = batchObjects[index]!! + val response = responseList[index] + val responseBody = response["body"] as? RawResponse + val insertHash = responseBody?.get("hash") as? String ?: run { + Log.w(TAG, "No hash returned for the configuration in namespace ${config.configNamespace()}") + return@forEachIndexed + } + Log.d(TAG, "Hash ${insertHash.take(4)} returned from store request for new config") + + // confirm pushed seqno + val thisSeqNo = toPushMessage.seqNo + config.confirmPushed(thisSeqNo, insertHash) + Log.d(TAG, "Successfully removed the deleted hashes from ${config.javaClass.simpleName}") + // dump and write config after successful + if (config.needsDump()) { // usually this will be true? + configFactory.persist(config, toPushMessage.sentTimestamp ?: sentTimestamp) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error performing batch request", e) + return delegate.handleJobFailed(this, dispatcherName, e) + } + delegate.handleJobSucceeded(this, dispatcherName) + if (shouldRunAgain.get() && storage.getConfigSyncJob(destination) == null) { + // reschedule if something has updated since we started this job + JobQueue.shared.add(ConfigurationSyncJob(destination)) + } + } + + fun Destination.destinationPublicKey(): String = when (this) { + is Destination.Contact -> publicKey + is Destination.ClosedGroup -> groupPublicKey + else -> throw NullPointerException("Not public key for this destination") + } + + override fun serialize(): Data { + val (type, address) = when (destination) { + is Destination.Contact -> CONTACT_TYPE to destination.publicKey + is Destination.ClosedGroup -> GROUP_TYPE to destination.groupPublicKey + else -> return Data.EMPTY + } + return Data.Builder() + .putInt(DESTINATION_TYPE_KEY, type) + .putString(DESTINATION_ADDRESS_KEY, address) + .build() + } + + override fun getFactoryKey(): String = KEY + + companion object { + const val TAG = "ConfigSyncJob" + const val KEY = "ConfigSyncJob" + + // Keys used for DB storage + const val DESTINATION_ADDRESS_KEY = "destinationAddress" + const val DESTINATION_TYPE_KEY = "destinationType" + + // type mappings + const val CONTACT_TYPE = 1 + const val GROUP_TYPE = 2 + + } + + class Factory: Job.Factory<ConfigurationSyncJob> { + override fun create(data: Data): ConfigurationSyncJob? { + if (!data.hasInt(DESTINATION_TYPE_KEY) || !data.hasString(DESTINATION_ADDRESS_KEY)) return null + + val address = data.getString(DESTINATION_ADDRESS_KEY) + val destination = when (data.getInt(DESTINATION_TYPE_KEY)) { + CONTACT_TYPE -> Destination.Contact(address) + GROUP_TYPE -> Destination.ClosedGroup(address) + else -> return null + } + + return ConfigurationSyncJob(destination) + } + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt index 38e8831fba..f0831b8bb6 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/GroupAvatarDownloadJob.kt @@ -3,27 +3,51 @@ package org.session.libsession.messaging.jobs import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.utilities.Data +import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.GroupUtil -class GroupAvatarDownloadJob(val room: String, val server: String) : Job { +class GroupAvatarDownloadJob(val server: String, val room: String, val imageId: String?) : Job { override var delegate: JobDelegate? = null override var id: String? = null override var failureCount: Int = 0 override val maxFailureCount: Int = 10 - override fun execute() { + override suspend fun execute(dispatcherName: String) { + if (imageId == null) { + delegate?.handleJobFailedPermanently(this, dispatcherName, Exception("GroupAvatarDownloadJob now requires imageId")) + return + } val storage = MessagingModuleConfiguration.shared.storage + val openGroup = storage.getOpenGroup(room, server) + if (openGroup == null || storage.getThreadId(openGroup) == null) { + delegate?.handleJobFailedPermanently(this, dispatcherName, Exception("GroupAvatarDownloadJob openGroup is null")) + return + } + val storedImageId = openGroup.imageId + + if (storedImageId == null || storedImageId != imageId) { + delegate?.handleJobFailedPermanently(this, dispatcherName, Exception("GroupAvatarDownloadJob imageId does not match the OpenGroup")) + return + } + try { - val info = OpenGroupApi.getRoomInfo(room, server).get() - val imageId = info.imageId ?: return - val bytes = OpenGroupApi.downloadOpenGroupProfilePicture(server, info.token, imageId).get() + val bytes = OpenGroupApi.downloadOpenGroupProfilePicture(server, room, imageId).get() + + // Once the download is complete the imageId might no longer match, so we need to fetch it again just in case + val postDownloadStoredImageId = storage.getOpenGroup(room, server)?.imageId + + if (postDownloadStoredImageId == null || postDownloadStoredImageId != imageId) { + delegate?.handleJobFailedPermanently(this, dispatcherName, Exception("GroupAvatarDownloadJob imageId no longer matches the OpenGroup")) + return + } + val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) storage.updateProfilePicture(groupId, bytes) - storage.updateTimestampUpdated(groupId, System.currentTimeMillis()) - delegate?.handleJobSucceeded(this) + storage.updateTimestampUpdated(groupId, SnodeAPI.nowWithOffset) + delegate?.handleJobSucceeded(this, dispatcherName) } catch (e: Exception) { - delegate?.handleJobFailed(this, e) + delegate?.handleJobFailed(this, dispatcherName, e) } } @@ -31,6 +55,7 @@ class GroupAvatarDownloadJob(val room: String, val server: String) : Job { return Data.Builder() .putString(ROOM, room) .putString(SERVER, server) + .putString(IMAGE_ID, imageId) .build() } @@ -41,14 +66,16 @@ class GroupAvatarDownloadJob(val room: String, val server: String) : Job { private const val ROOM = "room" private const val SERVER = "server" + private const val IMAGE_ID = "imageId" } class Factory : Job.Factory<GroupAvatarDownloadJob> { override fun create(data: Data): GroupAvatarDownloadJob { return GroupAvatarDownloadJob( + data.getString(SERVER), data.getString(ROOM), - data.getString(SERVER) + if (data.hasString(IMAGE_ID)) { data.getString(IMAGE_ID) } else { null } ) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt index 74feb83a61..7f3bf9b173 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/Job.kt @@ -17,7 +17,7 @@ interface Job { internal const val MAX_BUFFER_SIZE = 1_000_000 // bytes } - fun execute() + suspend fun execute(dispatcherName: String) fun serialize(): Data diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobDelegate.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobDelegate.kt index 535ea27f3c..769458ab6d 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/JobDelegate.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/JobDelegate.kt @@ -2,7 +2,7 @@ package org.session.libsession.messaging.jobs interface JobDelegate { - fun handleJobSucceeded(job: Job) - fun handleJobFailed(job: Job, error: Exception) - fun handleJobFailedPermanently(job: Job, error: Exception) + fun handleJobSucceeded(job: Job, dispatcherName: String) + fun handleJobFailed(job: Job, dispatcherName: String, error: Exception) + fun handleJobFailedPermanently(job: Job, dispatcherName: String, error: Exception) } \ No newline at end of file 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 215d20834a..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 @@ -26,7 +26,7 @@ class JobQueue : JobDelegate { private val jobTimestampMap = ConcurrentHashMap<Long, AtomicInteger>() private val rxDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() private val rxMediaDispatcher = Executors.newFixedThreadPool(4).asCoroutineDispatcher() - private val openGroupDispatcher = Executors.newCachedThreadPool().asCoroutineDispatcher() + private val openGroupDispatcher = Executors.newFixedThreadPool(8).asCoroutineDispatcher() private val txDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() private val scope = CoroutineScope(Dispatchers.Default) + SupervisorJob() private val queue = Channel<Job>(UNLIMITED) @@ -53,7 +53,7 @@ class JobQueue : JobDelegate { } if (openGroupId.isNullOrEmpty()) { Log.e("OpenGroupDispatcher", "Open Group ID was null on ${job.javaClass.simpleName}") - handleJobFailedPermanently(job, NullPointerException("Open Group ID was null")) + handleJobFailedPermanently(job, name, NullPointerException("Open Group ID was null")) } else { val groupChannel = if (!openGroupChannels.containsKey(openGroupId)) { Log.d("OpenGroupDispatcher", "Creating ${openGroupId.hashCode()} channel") @@ -94,10 +94,17 @@ class JobQueue : JobDelegate { } } - private fun Job.process(dispatcherName: String) { - Log.d(dispatcherName,"processJob: ${javaClass.simpleName}") + private suspend fun Job.process(dispatcherName: String) { + Log.d(dispatcherName,"processJob: ${javaClass.simpleName} (id: $id)") delegate = this@JobQueue - execute() + + try { + execute(dispatcherName) + } + catch (e: Exception) { + Log.d(dispatcherName, "unhandledJobException: ${javaClass.simpleName} (id: $id)") + this@JobQueue.handleJobFailed(this, dispatcherName, e) + } } init { @@ -115,9 +122,10 @@ class JobQueue : JobDelegate { while (isActive) { when (val job = queue.receive()) { - is NotifyPNServerJob, is AttachmentUploadJob, is MessageSendJob -> { + is NotifyPNServerJob, is AttachmentUploadJob, is MessageSendJob, is ConfigurationSyncJob -> { txQueue.send(job) } + is RetrieveProfileAvatarJob, is AttachmentDownloadJob -> { mediaQueue.send(job) } @@ -136,7 +144,7 @@ class JobQueue : JobDelegate { } } else -> { - throw IllegalStateException("Unexpected job type.") + throw IllegalStateException("Unexpected job type: ${job.getFactoryKey()}") } } } @@ -177,7 +185,7 @@ class JobQueue : JobDelegate { return } if (!pendingJobIds.add(id)) { - Log.e("Loki","tried to re-queue pending/in-progress job") + Log.e("Loki","tried to re-queue pending/in-progress job (id: $id)") return } queue.trySend(job) @@ -196,7 +204,7 @@ class JobQueue : JobDelegate { } } pendingJobs.sortedBy { it.id }.forEach { job -> - Log.i("Loki", "Resuming pending job of type: ${job::class.simpleName}.") + Log.i("Loki", "Resuming pending job of type: ${job::class.simpleName} (id: ${job.id}).") queue.trySend(job) // Offer always called on unlimited capacity } } @@ -217,27 +225,29 @@ class JobQueue : JobDelegate { GroupAvatarDownloadJob.KEY, BackgroundGroupAddJob.KEY, OpenGroupDeleteJob.KEY, + RetrieveProfileAvatarJob.KEY, + ConfigurationSyncJob.KEY, ) allJobTypes.forEach { type -> resumePendingJobs(type) } } - override fun handleJobSucceeded(job: Job) { + override fun handleJobSucceeded(job: Job, dispatcherName: String) { val jobId = job.id ?: return MessagingModuleConfiguration.shared.storage.markJobAsSucceeded(jobId) pendingJobIds.remove(jobId) } - override fun handleJobFailed(job: Job, error: Exception) { + override fun handleJobFailed(job: Job, dispatcherName: String, error: Exception) { // Canceled val storage = MessagingModuleConfiguration.shared.storage if (storage.isJobCanceled(job)) { - return Log.i("Loki", "${job::class.simpleName} canceled.") + return Log.i("Loki", "${job::class.simpleName} canceled (id: ${job.id}).") } // Message send jobs waiting for the attachment to upload if (job is MessageSendJob && error is MessageSendJob.AwaitingAttachmentUploadException) { - Log.i("Loki", "Message send job waiting for attachment upload to finish.") + Log.i("Loki", "Message send job waiting for attachment upload to finish (id: ${job.id}).") return } @@ -255,21 +265,22 @@ class JobQueue : JobDelegate { job.failureCount += 1 if (job.failureCount >= job.maxFailureCount) { - handleJobFailedPermanently(job, error) + handleJobFailedPermanently(job, dispatcherName, error) } else { storage.persistJob(job) val retryInterval = getRetryInterval(job) - Log.i("Loki", "${job::class.simpleName} failed; scheduling retry (failure count is ${job.failureCount}).") + Log.i("Loki", "${job::class.simpleName} failed (id: ${job.id}); scheduling retry (failure count is ${job.failureCount}).") timer.schedule(delay = retryInterval) { - Log.i("Loki", "Retrying ${job::class.simpleName}.") + Log.i("Loki", "Retrying ${job::class.simpleName} (id: ${job.id}).") queue.trySend(job) } } } - override fun handleJobFailedPermanently(job: Job, error: Exception) { + override fun handleJobFailedPermanently(job: Job, dispatcherName: String, error: Exception) { val jobId = job.id ?: return handleJobFailedPermanently(jobId) + Log.d(dispatcherName, "permanentlyFailedJob: ${javaClass.simpleName} (id: ${job.id})") } private fun handleJobFailedPermanently(jobId: String) { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt index 439fbb7a3a..1ac482d5b4 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageReceiveJob.kt @@ -3,6 +3,7 @@ package org.session.libsession.messaging.jobs import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.sending_receiving.MessageReceiver import org.session.libsession.messaging.sending_receiving.handle import org.session.libsession.messaging.utilities.Data @@ -25,46 +26,48 @@ class MessageReceiveJob(val data: ByteArray, val serverHash: String? = null, val private val OPEN_GROUP_ID_KEY = "open_group_id" } - override fun execute() { - executeAsync().get() + override suspend fun execute(dispatcherName: String) { + executeAsync(dispatcherName).get() } - fun executeAsync(): Promise<Unit, Exception> { + fun executeAsync(dispatcherName: String): Promise<Unit, Exception> { val deferred = deferred<Unit, Exception>() try { - val isRetry: Boolean = failureCount != 0 + val storage = MessagingModuleConfiguration.shared.storage val serverPublicKey = openGroupID?.let { - MessagingModuleConfiguration.shared.storage.getOpenGroupPublicKey(it.split(".").dropLast(1).joinToString(".")) + storage.getOpenGroupPublicKey(it.split(".").dropLast(1).joinToString(".")) } - val (message, proto) = MessageReceiver.parse(this.data, this.openGroupMessageServerID, openGroupPublicKey = serverPublicKey) + val currentClosedGroups = storage.getAllActiveClosedGroupPublicKeys() + val (message, proto) = MessageReceiver.parse(this.data, this.openGroupMessageServerID, openGroupPublicKey = serverPublicKey, currentClosedGroups = currentClosedGroups) + val threadId = Message.getThreadId(message, this.openGroupID, storage, false) message.serverHash = serverHash - MessageReceiver.handle(message, proto, this.openGroupID) - this.handleSuccess() + MessageReceiver.handle(message, proto, threadId ?: -1, this.openGroupID) + this.handleSuccess(dispatcherName) deferred.resolve(Unit) } catch (e: Exception) { Log.e(TAG, "Couldn't receive message.", e) if (e is MessageReceiver.Error && !e.isRetryable) { Log.e("Loki", "Message receive job permanently failed.", e) - this.handlePermanentFailure(e) + this.handlePermanentFailure(dispatcherName, e) } else { Log.e("Loki", "Couldn't receive message.", e) - this.handleFailure(e) + this.handleFailure(dispatcherName, e) } deferred.resolve(Unit) // The promise is just used to keep track of when we're done } return deferred.promise } - private fun handleSuccess() { - delegate?.handleJobSucceeded(this) + private fun handleSuccess(dispatcherName: String) { + delegate?.handleJobSucceeded(this, dispatcherName) } - private fun handlePermanentFailure(e: Exception) { - delegate?.handleJobFailedPermanently(this, e) + private fun handlePermanentFailure(dispatcherName: String, e: Exception) { + delegate?.handleJobFailedPermanently(this, dispatcherName, e) } - private fun handleFailure(e: Exception) { - delegate?.handleJobFailed(this, e) + private fun handleFailure(dispatcherName: String, e: Exception) { + delegate?.handleJobFailed(this, dispatcherName, e) } override fun serialize(): Data { diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt index cdd9e0a3ac..2a152d0a01 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt @@ -10,7 +10,7 @@ import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data -import org.session.libsession.snode.OnionRequestAPI +import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log class MessageSendJob(val message: Message, val destination: Destination) : Job { @@ -32,7 +32,7 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job { private val DESTINATION_KEY = "destination" } - override fun execute() { + override suspend fun execute(dispatcherName: String) { val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val message = message as? VisibleMessage val storage = MessagingModuleConfiguration.shared.storage @@ -60,21 +60,33 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job { } } if (attachmentsToUpload.isNotEmpty()) { - this.handleFailure(AwaitingAttachmentUploadException) + this.handleFailure(dispatcherName, AwaitingAttachmentUploadException) return } // Wait for all attachments to upload before continuing } - val promise = MessageSender.send(this.message, this.destination).success { - this.handleSuccess() + val isSync = destination is Destination.Contact && destination.publicKey == sender + val promise = MessageSender.send(this.message, this.destination, isSync).success { + this.handleSuccess(dispatcherName) }.fail { exception -> - Log.e(TAG, "Couldn't send message due to error: $exception.") - if (exception is MessageSender.Error) { - if (!exception.isRetryable) { this.handlePermanentFailure(exception) } + var logStacktrace = true + + when (exception) { + // No need for the stack trace for HTTP errors + is HTTP.HTTPRequestFailedException -> { + logStacktrace = false + + if (exception.statusCode == 429) { this.handlePermanentFailure(dispatcherName, exception) } + else { this.handleFailure(dispatcherName, exception) } + } + is MessageSender.Error -> { + if (!exception.isRetryable) { this.handlePermanentFailure(dispatcherName, exception) } + else { this.handleFailure(dispatcherName, exception) } + } + else -> this.handleFailure(dispatcherName, exception) } - if (exception is OnionRequestAPI.HTTPRequestFailedAtDestinationException && exception.statusCode == 429) { - this.handlePermanentFailure(exception) - } - this.handleFailure(exception) + + if (logStacktrace) { Log.e(TAG, "Couldn't send message due to error", exception) } + else { Log.e(TAG, "Couldn't send message due to error: ${exception.message}") } } try { promise.get() @@ -83,15 +95,15 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job { } } - private fun handleSuccess() { - delegate?.handleJobSucceeded(this) + private fun handleSuccess(dispatcherName: String) { + delegate?.handleJobSucceeded(this, dispatcherName) } - private fun handlePermanentFailure(error: Exception) { - delegate?.handleJobFailedPermanently(this, error) + private fun handlePermanentFailure(dispatcherName: String, error: Exception) { + delegate?.handleJobFailedPermanently(this, dispatcherName, error) } - private fun handleFailure(error: Exception) { + private fun handleFailure(dispatcherName: String, error: Exception) { Log.w(TAG, "Failed to send $message::class.simpleName.") val message = message as? VisibleMessage if (message != null) { @@ -99,7 +111,7 @@ class MessageSendJob(val message: Message, val destination: Destination) : Job { return // The message has been deleted } } - delegate?.handleJobFailed(this, error) + delegate?.handleJobFailed(this, dispatcherName, error) } override fun serialize(): Data { 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 5c393c97b5..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,20 +3,17 @@ 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.SnodeMessage import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.SnodeMessage import org.session.libsession.snode.Version - -import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.JsonUtil +import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.retryIfNeeded class NotifyPNServerJob(val message: SnodeMessage) : Job { @@ -32,34 +29,38 @@ class NotifyPNServerJob(val message: SnodeMessage) : Job { private val MESSAGE_KEY = "message" } - override fun execute() { - val server = PushNotificationAPI.server + override suspend fun execute(dispatcherName: String) { + 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 { - handleSuccess() - }. fail { - handleFailure(it) + } success { + handleSuccess(dispatcherName) + } fail { + handleFailure(dispatcherName, it) } } - private fun handleSuccess() { - delegate?.handleJobSucceeded(this) + private fun handleSuccess(dispatcherName: String) { + delegate?.handleJobSucceeded(this, dispatcherName) } - private fun handleFailure(error: Exception) { - delegate?.handleJobFailed(this, error) + private fun handleFailure(dispatcherName: String, error: Exception) { + delegate?.handleJobFailed(this, dispatcherName, error) } override fun serialize(): Data { 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 c4180c0025..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 @@ -19,18 +19,32 @@ class OpenGroupDeleteJob(private val messageServerIds: LongArray, private val th override var failureCount: Int = 0 override val maxFailureCount: Int = 1 - override fun execute() { + override suspend fun execute(dispatcherName: String) { val dataProvider = MessagingModuleConfiguration.shared.messageDataProvider val numberToDelete = messageServerIds.size - Log.d(TAG, "Deleting $numberToDelete messages") - var numberDeleted = 0 - messageServerIds.forEach { serverId -> - val (messageId, isSms) = dataProvider.getMessageID(serverId, threadId) ?: return@forEach - dataProvider.deleteMessage(messageId, isSms) - numberDeleted++ + 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 { + val messageIds = dataProvider.getMessageIDs(messageServerIds.toList(), threadId) + + // Delete the SMS messages + if (messageIds.first.isNotEmpty()) { + dataProvider.deleteMessages(messageIds.first, threadId, true) + } + + // Delete the MMS messages + if (messageIds.second.isNotEmpty()) { + dataProvider.deleteMessages(messageIds.second, threadId, false) + } + + Log.d(TAG, "Deleted ${messageIds.first.size + messageIds.second.size} messages successfully") + delegate?.handleJobSucceeded(this, dispatcherName) + } + catch (e: Exception) { + Log.w(TAG, "OpenGroupDeleteJob failed: $e") + delegate?.handleJobFailed(this, dispatcherName, e) } - Log.d(TAG, "Deleted $numberDeleted messages successfully") - delegate?.handleJobSucceeded(this) } override fun serialize(): Data = Data.Builder() diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt new file mode 100644 index 0000000000..9ca2534f66 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt @@ -0,0 +1,121 @@ +package org.session.libsession.messaging.jobs + +import org.session.libsession.avatars.AvatarHelper +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.utilities.Data +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.DownloadUtilities.downloadFile +import org.session.libsession.utilities.TextSecurePreferences.Companion.setProfileAvatarId +import org.session.libsession.utilities.TextSecurePreferences.Companion.setProfilePictureURL +import org.session.libsession.utilities.Util.copy +import org.session.libsession.utilities.Util.equals +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsignal.streams.ProfileCipherInputStream +import org.session.libsignal.utilities.Log +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.security.SecureRandom +import java.util.concurrent.ConcurrentSkipListSet + +class RetrieveProfileAvatarJob(private val profileAvatar: String?, val recipientAddress: Address): Job { + override var delegate: JobDelegate? = null + override var id: String? = null + override var failureCount: Int = 0 + override val maxFailureCount: Int = 3 + + companion object { + val TAG = RetrieveProfileAvatarJob::class.simpleName + val KEY: String = "RetrieveProfileAvatarJob" + + // Keys used for database storage + private const val PROFILE_AVATAR_KEY = "profileAvatar" + private const val RECEIPIENT_ADDRESS_KEY = "recipient" + + val errorUrls = ConcurrentSkipListSet<String>() + + } + + override suspend fun execute(dispatcherName: String) { + val delegate = delegate ?: return + if (profileAvatar in errorUrls) return delegate.handleJobFailed(this, dispatcherName, Exception("Profile URL 404'd this app instance")) + val context = MessagingModuleConfiguration.shared.context + val storage = MessagingModuleConfiguration.shared.storage + val recipient = Recipient.from(context, recipientAddress, true) + val profileKey = recipient.resolve().profileKey + + if (profileKey == null || (profileKey.size != 32 && profileKey.size != 16)) { + return delegate.handleJobFailedPermanently(this, dispatcherName, Exception("Recipient profile key is gone!")) + } + + // Commit '78d1e9d' (fix: open group threads and avatar downloads) had this commented out so + // it's now limited to just the current user case + if ( + recipient.isLocalNumber && + AvatarHelper.avatarFileExists(context, recipient.resolve().address) && + equals(profileAvatar, recipient.resolve().profileAvatar) + ) { + Log.w(TAG, "Already retrieved profile avatar: $profileAvatar") + return + } + + if (profileAvatar.isNullOrEmpty()) { + Log.w(TAG, "Removing profile avatar for: " + recipient.address.serialize()) + + if (recipient.isLocalNumber) { + setProfileAvatarId(context, SecureRandom().nextInt()) + setProfilePictureURL(context, null) + } + + AvatarHelper.delete(context, recipient.address) + storage.setProfileAvatar(recipient, null) + return + } + + val downloadDestination = File.createTempFile("avatar", ".jpg", context.cacheDir) + + try { + downloadFile(downloadDestination, profileAvatar) + val avatarStream: InputStream = ProfileCipherInputStream(FileInputStream(downloadDestination), profileKey) + val decryptDestination = File.createTempFile("avatar", ".jpg", context.cacheDir) + copy(avatarStream, FileOutputStream(decryptDestination)) + decryptDestination.renameTo(AvatarHelper.getAvatarFile(context, recipient.address)) + + if (recipient.isLocalNumber) { + setProfileAvatarId(context, SecureRandom().nextInt()) + setProfilePictureURL(context, profileAvatar) + } + + storage.setProfileAvatar(recipient, profileAvatar) + } catch (e: Exception) { + Log.e("Loki", "Failed to download profile avatar", e) + if (failureCount + 1 >= maxFailureCount) { + errorUrls += profileAvatar + } + return delegate.handleJobFailed(this, dispatcherName, e) + } finally { + downloadDestination.delete() + } + return delegate.handleJobSucceeded(this, dispatcherName) + } + + override fun serialize(): Data { + return Data.Builder() + .putString(PROFILE_AVATAR_KEY, profileAvatar) + .putString(RECEIPIENT_ADDRESS_KEY, recipientAddress.serialize()) + .build() + } + + override fun getFactoryKey(): String { + return KEY + } + + class Factory: Job.Factory<RetrieveProfileAvatarJob> { + override fun create(data: Data): RetrieveProfileAvatarJob { + val profileAvatar = if (data.hasString(PROFILE_AVATAR_KEY)) { data.getString(PROFILE_AVATAR_KEY) } else { null } + val recipientAddress = Address.fromSerialized(data.getString(RECEIPIENT_ADDRESS_KEY)) + return RetrieveProfileAvatarJob(profileAvatar, recipientAddress) + } + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt index cfe792274f..46c87d5b90 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt @@ -16,6 +16,7 @@ class SessionJobManagerFactories { GroupAvatarDownloadJob.KEY to GroupAvatarDownloadJob.Factory(), BackgroundGroupAddJob.KEY to BackgroundGroupAddJob.Factory(), OpenGroupDeleteJob.KEY to OpenGroupDeleteJob.Factory(), + ConfigurationSyncJob.KEY to ConfigurationSyncJob.Factory() ) } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt index e02a2f00e5..cc388b0376 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/TrimThreadJob.kt @@ -20,7 +20,7 @@ class TrimThreadJob(val threadId: Long, val openGroupId: String?) : Job { const val THREAD_LENGTH_TRIGGER_SIZE = 2000 } - override fun execute() { + override suspend fun execute(dispatcherName: String) { val context = MessagingModuleConfiguration.shared.context val trimmingEnabled = TextSecurePreferences.isThreadLengthTrimmingEnabled(context) val storage = MessagingModuleConfiguration.shared.storage @@ -29,7 +29,7 @@ class TrimThreadJob(val threadId: Long, val openGroupId: String?) : Job { val oldestMessageTime = System.currentTimeMillis() - TRIM_TIME_LIMIT storage.trimThreadBefore(threadId, oldestMessageTime) } - delegate?.handleJobSucceeded(this) + delegate?.handleJobSucceeded(this, dispatcherName) } override fun serialize(): Data { 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 e9eae0ba5a..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,6 +2,7 @@ package org.session.libsession.messaging.mentions import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.contacts.Contact +import java.util.Locale object MentionsManager { var userPublicKeyCache = mutableMapOf<Long, Set<String>>() // Thread ID to set of user hex encoded public keys @@ -32,9 +33,9 @@ object MentionsManager { candidates.sortedBy { it.displayName } if (query.length >= 2) { // Filter out any non-matching candidates - candidates = candidates.filter { it.displayName.toLowerCase().contains(query.toLowerCase()) } + candidates = candidates.filter { it.displayName.lowercase(Locale.getDefault()).contains(query.lowercase(Locale.getDefault())) } // Sort based on where in the candidate the query occurs - candidates.sortedBy { it.displayName.toLowerCase().indexOf(query.toLowerCase()) } + candidates.sortedBy { it.displayName.lowercase(Locale.getDefault()).indexOf(query.lowercase(Locale.getDefault())) } } // Return return candidates 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 3abf0ed3e1..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 @@ -7,13 +7,13 @@ import org.session.libsignal.utilities.toHexString sealed class Destination { - class Contact(var publicKey: String) : Destination() { + data class Contact(var publicKey: String) : Destination() { internal constructor(): this("") } - class ClosedGroup(var groupPublicKey: String) : Destination() { + data class ClosedGroup(var groupPublicKey: String) : Destination() { internal constructor(): this("") } - class LegacyOpenGroup(var roomToken: String, var server: String) : Destination() { + data class LegacyOpenGroup(var roomToken: String, var server: String) : Destination() { internal constructor(): this("", "") } @@ -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 d201daa98d..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,8 +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 @@ -11,29 +17,79 @@ abstract class Message { var receivedTimestamp: Long? = null var recipient: String? = null var sender: String? = null + var isSenderSelf: Boolean = false 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 - 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 + companion object { + fun getThreadId(message: Message, openGroupID: String?, storage: StorageProtocol, shouldCreateThread: Boolean): Long? { + val senderOrSync = when (message) { + is VisibleMessage -> message.syncTarget ?: message.sender!! + is ExpirationTimerUpdate -> message.syncTarget ?: message.sender!! + else -> message.sender!! + } + return storage.getThreadIdFor(senderOrSync, message.groupPublicKey, openGroupID, createThread = shouldCreateThread) + } } + 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 30a47ab85b..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() } } @@ -122,14 +120,19 @@ class ConfigurationMessage(var closedGroups: List<ClosedGroup>, var openGroups: val displayName = TextSecurePreferences.getProfileName(context) ?: return null val profilePicture = TextSecurePreferences.getProfilePictureURL(context) val profileKey = ProfileKeyUtil.getProfileKey(context) - val groups = storage.getAllGroups() + val groups = storage.getAllGroups(includeInactive = false) for (group in groups) { - if (group.isClosedGroup) { + if (group.isClosedGroup && group.isActive) { 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 f5a65e4ca4..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,17 +1,26 @@ 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 -class MessageRequestResponse(val isApproved: Boolean) : ControlMessage() { +class MessageRequestResponse(val isApproved: Boolean, var profile: Profile? = null) : ControlMessage() { override val isSelfSendValid: Boolean = true override fun toProto(): SignalServiceProtos.Content? { + val profileProto = SignalServiceProtos.DataMessage.LokiProfile.newBuilder() + profile?.displayName?.let { profileProto.displayName = it } + profile?.profilePictureURL?.let { profileProto.profilePicture = it } val messageRequestResponseProto = SignalServiceProtos.MessageRequestResponse.newBuilder() .setIsApproved(isApproved) + .setProfile(profileProto.build()) + profile?.profileKey?.let { messageRequestResponseProto.profileKey = ByteString.copyFrom(it) } return try { SignalServiceProtos.Content.newBuilder() + .applyExpiryMode() .setMessageRequestResponse(messageRequestResponseProto.build()) .build() } catch (e: Exception) { @@ -26,8 +35,14 @@ class MessageRequestResponse(val isApproved: Boolean) : ControlMessage() { fun fromProto(proto: SignalServiceProtos.Content): MessageRequestResponse? { val messageRequestResponseProto = if (proto.hasMessageRequestResponse()) proto.messageRequestResponse else return null val isApproved = messageRequestResponseProto.isApproved - return MessageRequestResponse(isApproved) + val profileProto = messageRequestResponseProto.profile + val profile = Profile().apply { + displayName = profileProto.displayName + profileKey = if (messageRequestResponseProto.hasProfileKey()) messageRequestResponseProto.profileKey.toByteArray() else null + 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 new file mode 100644 index 0000000000..7ec4eaa6f0 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/messages/control/SharedConfigurationMessage.kt @@ -0,0 +1,34 @@ +package org.session.libsession.messaging.messages.control + +import com.google.protobuf.ByteString +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage + +class SharedConfigurationMessage(val kind: SharedConfigMessage.Kind, val data: ByteArray, val seqNo: Long): ControlMessage() { + + override val ttl: Long = 30 * 24 * 60 * 60 * 1000L + override val isSelfSendValid: Boolean = true + + companion object { + 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 { + if (!super.isValid()) return false + return data.isNotEmpty() && seqNo >= 0 + } + + override fun toProto(): SignalServiceProtos.Content? { + val sharedConfigurationMessage = SharedConfigMessage.newBuilder() + .setKind(kind) + .setSeqno(seqNo) + .setData(ByteString.copyFrom(data)) + .build() + return SignalServiceProtos.Content.newBuilder() + .setSharedConfigMessage(sharedConfigurationMessage) + .build() + } +} \ No newline at end of file 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 e5160b7543..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,9 +26,11 @@ 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; + private final boolean hasMention; private final DataExtractionNotificationInfoMessage dataExtractionNotification; private final QuoteModel quote; @@ -41,9 +43,11 @@ public class IncomingMediaMessage { long sentTimeMillis, int subscriptionId, long expiresIn, + long expireStartedAt, boolean expirationUpdate, boolean unidentified, boolean messageRequestResponse, + boolean hasMention, Optional<String> body, Optional<SignalServiceGroup> group, Optional<List<SignalServiceAttachment>> attachments, @@ -58,11 +62,13 @@ 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(); this.unidentified = unidentified; this.messageRequestResponse = messageRequestResponse; + this.hasMention = hasMention; if (group.isPresent()) this.groupId = Address.fromSerialized(GroupUtil.INSTANCE.getEncodedId(group.get())); else this.groupId = null; @@ -75,13 +81,15 @@ 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, - false, false, Optional.fromNullable(message.getText()), group, Optional.fromNullable(attachments), quote, Optional.absent(), linkPreviews, Optional.absent()); + 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()); } public int getSubscriptionId() { @@ -120,10 +128,18 @@ public class IncomingMediaMessage { return expiresIn; } + public long getExpireStartedAt() { + return expireStartedAt; + } + public boolean isGroupMessage() { return groupId != null; } + public boolean hasMention() { + return hasMention; + } + public boolean isScreenshotDataExtraction() { if (dataExtractionNotification == null) return false; else { 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 93347f5276..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,26 +41,28 @@ 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; private boolean isOpenGroupInvitation = false; public IncomingTextMessage(Address sender, int senderDeviceId, long sentTimestampMillis, String encodedBody, Optional<SignalServiceGroup> group, - long expiresInMillis, boolean unidentified) { - this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, unidentified, -1); + 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) { - this(sender, senderDeviceId, sentTimestampMillis, encodedBody, group, expiresInMillis, unidentified, callType, 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 isPush) { + long expiresInMillis, long expireStartedAt, boolean unidentified, int callType, boolean hasMention, boolean isPush) { this.message = encodedBody; this.sender = sender; this.senderDeviceId = senderDeviceId; @@ -72,8 +74,10 @@ 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; if (group.isPresent()) { this.groupId = Address.fromSerialized(GroupUtil.getEncodedId(group.get())); @@ -95,9 +99,11 @@ 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(); + this.hasMention = in.readInt() == 1; } public IncomingTextMessage(IncomingTextMessage base, String newBody) { @@ -113,27 +119,33 @@ 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; + this.hasMention = base.hasMention; } 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); + 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); + IncomingTextMessage incomingTextMessage = new IncomingTextMessage(sender, 1, sentTimestamp, body, Optional.absent(), expiresInMillis, expireStartedAt, false, false); incomingTextMessage.isOpenGroupInvitation = true; return incomingTextMessage; } @@ -141,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); + long sentTimestamp, + long expiresInMillis, + long expireStartedAt) { + return new IncomingTextMessage(sender, 1, sentTimestamp, null, group, expiresInMillis, expireStartedAt, false, callMessageType.ordinal(), false, false); } public int getSubscriptionId() { @@ -153,6 +167,10 @@ public class IncomingTextMessage implements Parcelable { return expiresInMillis; } + public long getExpireStartedAt() { + return expireStartedAt; + } + public long getSentTimestampMillis() { return sentTimestampMillis; } @@ -207,6 +225,8 @@ public class IncomingTextMessage implements Parcelable { public boolean isOpenGroupInvitation() { return isOpenGroupInvitation; } + public boolean hasMention() { return hasMention; } + public boolean isCallInfo() { int callMessageTypeLength = CallMessageType.values().length; return callType >= 0 && callType < callMessageTypeLength; @@ -240,5 +260,6 @@ public class IncomingTextMessage implements Parcelable { out.writeInt(unidentified ? 1 : 0); out.writeInt(isOpenGroupInvitation ? 1 : 0); out.writeInt(callType); + out.writeInt(hasMention ? 1 : 0); } } 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 a163b667d9..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,15 +81,17 @@ 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, Collections.emptyList(), - previews, Collections.emptyList(), Collections.emptyList()); + expiresInMillis, expireStartedAt, DistributionTypes.DEFAULT, outgoingQuote, + Collections.emptyList(), previews, Collections.emptyList(), Collections.emptyList()); } public Recipient getRecipient() { @@ -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 cf792e6a84..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() { } } - internal 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 2891400c9a..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,27 +3,29 @@ 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 -class VisibleMessage : Message() { - /** 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 text: String? = null - val attachmentIDs: MutableList<Long> = mutableListOf() - var quote: Quote? = null - var linkPreview: LinkPreview? = null - var profile: Profile? = null - var openGroupInvitation: OpenGroupInvitation? = null - var reaction: Reaction? = null +/** + * @param syncTarget 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 VisibleMessage( + var syncTarget: String? = null, + var text: String? = null, + val attachmentIDs: MutableList<Long> = mutableListOf(), + var quote: Quote? = null, + var linkPreview: LinkPreview? = null, + var profile: Profile? = null, + var openGroupInvitation: OpenGroupInvitation? = null, + var reaction: Reaction? = null, + var hasMention: Boolean = false, + var blocksMessageRequests: Boolean = false +) : Message() { override val isSelfSendValid: Boolean = true @@ -42,49 +44,26 @@ class VisibleMessage : Message() { 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 @@ -119,25 +98,19 @@ class VisibleMessage : Message() { 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 @@ -164,4 +137,4 @@ class VisibleMessage : Message() { 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/OpenGroup.kt b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt index 9efeaf15d0..80a9a1e501 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/open_groups/OpenGroup.kt @@ -11,16 +11,19 @@ data class OpenGroup( val id: String, val name: String, val publicKey: String, + val imageId: String?, val infoUpdates: Int, + val canWrite: Boolean, ) { - - constructor(server: String, room: String, name: String, infoUpdates: Int, publicKey: String) : this( + constructor(server: String, room: String, publicKey: String, name: String, imageId: String?, canWrite: Boolean, infoUpdates: Int) : this( server = server, room = room, id = "$server.$room", name = name, publicKey = publicKey, + imageId = imageId, infoUpdates = infoUpdates, + canWrite = canWrite ) companion object { @@ -29,13 +32,14 @@ data class OpenGroup( return try { val json = JsonUtil.fromJson(jsonAsString) if (!json.has("room")) return null - val room = json.get("room").asText().toLowerCase(Locale.US) - val server = json.get("server").asText().toLowerCase(Locale.US) + val room = json.get("room").asText().lowercase(Locale.US) + val server = json.get("server").asText().lowercase(Locale.US) val displayName = json.get("displayName").asText() val publicKey = json.get("publicKey").asText() + val imageId = if (json.hasNonNull("imageId")) { json.get("imageId")?.asText() } else { null } + val canWrite = json.get("canWrite")?.asText()?.toBoolean() ?: true val infoUpdates = json.get("infoUpdates")?.asText()?.toIntOrNull() ?: 0 - val capabilities = json.get("capabilities")?.asText()?.split(",") ?: emptyList() - OpenGroup(server, room, displayName, infoUpdates, publicKey) + OpenGroup(server = server, room = room, name = displayName, publicKey = publicKey, imageId = imageId, canWrite = canWrite, infoUpdates = infoUpdates) } catch (e: Exception) { Log.w("Loki", "Couldn't parse open group from JSON: $jsonAsString.", e); null @@ -53,12 +57,14 @@ data class OpenGroup( } } - fun toJson(): Map<String,String> = mapOf( + fun toJson(): Map<String,String?> = mapOf( "room" to room, "server" to server, - "displayName" to name, "publicKey" to publicKey, + "displayName" to name, + "imageId" to imageId, "infoUpdates" to infoUpdates.toString(), + "canWrite" to canWrite.toString() ) val joinURL: String get() = "$server/$room?public_key=$publicKey" 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 dbb5d6dd76..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,8 +21,10 @@ 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 import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Base64.decode import org.session.libsignal.utilities.Base64.encodeBytes @@ -47,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) @@ -91,7 +92,7 @@ object OpenGroupApi { val created: Long = 0, val activeUsers: Int = 0, val activeUsersCutoff: Int = 0, - val imageId: Long? = null, + val imageId: String? = null, val pinnedMessages: List<PinnedMessage> = emptyList(), val admin: Boolean = false, val globalAdmin: Boolean = false, @@ -108,7 +109,24 @@ object OpenGroupApi { val defaultWrite: Boolean = false, val upload: Boolean = false, val defaultUpload: Boolean = false, - ) + ) { + fun toPollInfo() = RoomPollInfo( + token = token, + activeUsers = activeUsers, + admin = admin, + globalAdmin = globalAdmin, + moderator = moderator, + globalModerator = globalModerator, + read = read, + defaultRead = defaultRead, + defaultAccessible = defaultAccessible, + write = write, + defaultWrite = defaultWrite, + upload = upload, + defaultUpload = defaultUpload, + details = this + ) + } @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) data class PinnedMessage( @@ -148,7 +166,7 @@ object OpenGroupApi { ) enum class Capability { - BLIND, REACTIONS + SOGS, BLIND, REACTIONS } @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) @@ -300,8 +318,9 @@ object OpenGroupApi { ?: return Promise.ofFail(Error.NoEd25519KeyPair) val urlRequest = urlBuilder.toString() val headers = request.headers.toMutableMap() + val nonce = sodium.nonce(16) - val timestamp = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + val timestamp = TimeUnit.MILLISECONDS.toSeconds(SnodeAPI.nowWithOffset) var pubKey = "" var signature = ByteArray(Sign.BYTES) var bodyHash = ByteArray(0) @@ -380,7 +399,11 @@ object OpenGroupApi { } return if (request.useOnionRouting) { OnionRequestAPI.sendOnionRequest(requestBuilder.build(), request.server, publicKey).fail { e -> - Log.e("SOGS", "Failed onion request", e) + when (e) { + // No need for the stack trace for HTTP errors + is HTTP.HTTPRequestFailedException -> Log.e("SOGS", "Failed onion request: ${e.message}") + else -> Log.e("SOGS", "Failed onion request", e) + } } } else { Promise.ofFail(IllegalStateException("It's currently not allowed to send non onion routed requests.")) @@ -392,13 +415,13 @@ object OpenGroupApi { fun downloadOpenGroupProfilePicture( server: String, roomID: String, - imageId: Long + imageId: String ): Promise<ByteArray, Exception> { val request = Request( verb = GET, room = roomID, server = server, - endpoint = Endpoint.RoomFileIndividual(roomID, imageId.toString()) + endpoint = Endpoint.RoomFileIndividual(roomID, imageId) ) return getResponseBody(request) } @@ -577,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.") } @@ -634,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, @@ -644,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), @@ -728,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( @@ -947,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 6a38a551f8..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 @@ -9,11 +9,13 @@ import org.session.libsession.messaging.messages.control.DataExtractionNotificat import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.messages.control.SharedConfigurationMessage import org.session.libsession.messaging.messages.control.TypingIndicator import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.utilities.SessionId import org.session.libsession.messaging.utilities.SodiumUtilities +import org.session.libsession.snode.SnodeAPI import org.session.libsignal.crypto.PushTransportDetails import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.IdPrefix @@ -33,13 +35,16 @@ object MessageReceiver { object NoThread: Error("Couldn't find thread for message.") object SelfSend: Error("Message addressed at self.") object InvalidGroupPublicKey: Error("Invalid group public key.") + 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 -> false + is SenderBlocked, is SelfSend, + is ExpiredMessage, is NoGroupThread -> false else -> true } } @@ -50,6 +55,7 @@ object MessageReceiver { isOutgoing: Boolean? = null, otherBlindedPublicKey: String? = null, openGroupPublicKey: String? = null, + currentClosedGroups: Set<String>? ): Pair<Message, SignalServiceProtos.Content> { val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey() @@ -69,7 +75,7 @@ object MessageReceiver { } else { when (envelope.type) { SignalServiceProtos.Envelope.Type.SESSION_MESSAGE -> { - if (IdPrefix.fromValue(envelope.source) == IdPrefix.BLINDED) { + if (IdPrefix.fromValue(envelope.source)?.isBlinded() == true) { openGroupPublicKey ?: throw Error.InvalidGroupPublicKey otherBlindedPublicKey ?: throw Error.DecryptionFailed val decryptionResult = MessageDecrypter.decryptBlinded( @@ -138,13 +144,16 @@ object MessageReceiver { UnsendRequest.fromProto(proto) ?: MessageRequestResponse.fromProto(proto) ?: CallMessage.fromProto(proto) ?: - VisibleMessage.fromProto(proto) ?: run { - throw Error.UnknownMessage - } + SharedConfigurationMessage.fromProto(proto) ?: + 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 + 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 if (isOpenGroupMessage && message !is VisibleMessage) { @@ -154,7 +163,7 @@ object MessageReceiver { message.sender = sender message.recipient = userPublicKey message.sentTimestamp = envelope.timestamp - message.receivedTimestamp = if (envelope.hasServerTimestamp()) envelope.serverTimestamp else System.currentTimeMillis() + message.receivedTimestamp = if (envelope.hasServerTimestamp()) envelope.serverTimestamp else SnodeAPI.nowWithOffset message.groupPublicKey = groupPublicKey message.openGroupServerMessageID = openGroupServerID // Validate @@ -166,12 +175,16 @@ object MessageReceiver { // If the message failed to process the first time around we retry it later (if the error is retryable). In this case the timestamp // will already be in the database but we don't want to treat the message as a duplicate. The isRetry flag is a simple workaround // for this issue. - if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) { + if (groupPublicKey != null && groupPublicKey !in (currentClosedGroups ?: emptySet())) { + throw Error.NoGroupThread + } + if ((message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) || message is SharedConfigurationMessage) { // Allow duplicates in this case to avoid the following situation: // • The app performed a background poll or received a push notification // • This method was invoked and the received message timestamps table was updated // • Processing wasn't finished // • The user doesn't see the new closed group + // also allow shared configuration messages to be duplicates since we track hashes separately use seqno for conflict resolution } else { if (storage.isDuplicateMessage(envelope.timestamp)) { throw Error.DuplicateMessage } storage.addReceivedMessageTimestamp(envelope.timestamp) @@ -179,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 d6a4618d96..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,13 +9,15 @@ 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 import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.SharedConfigurationMessage import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.LinkPreview -import org.session.libsession.messaging.messages.visible.Profile import org.session.libsession.messaging.messages.visible.Quote import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi @@ -25,15 +28,18 @@ 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.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 @@ -66,115 +72,138 @@ object MessageSender { } // Convenience - fun send(message: Message, destination: Destination): Promise<Unit, Exception> { + 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 { - sendToSnodeDestination(destination, message) + sendToSnodeDestination(destination, message, isSyncMessage) } } + // One-on-One Chats & Closed Groups + @Throws(Exception::class) + fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage { + val storage = MessagingModuleConfiguration.shared.storage + val userPublicKey = storage.getUserPublicKey() + // Set the timestamp, sender and recipient + 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 + else -> throw IllegalStateException("Destination should not be an open group.") + } + + val isSelfSend = (message.recipient == userPublicKey) + // Validate the message + if (!message.isValid()) { + throw Error.InvalidMessage + } + // Stop here if this is a self-send, unless it's: + // • a configuration message + // • a sync message + // • a closed group control message of type `new` + var isNewClosedGroupControlMessage = false + if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) isNewClosedGroupControlMessage = + true + if (isSelfSend + && message !is ConfigurationMessage + && !isSyncMessage + && !isNewClosedGroupControlMessage + && message !is UnsendRequest + && message !is SharedConfigurationMessage + ) { + throw Error.InvalidMessage + } + // Attach the user's profile if needed + if (message is VisibleMessage) { + message.profile = storage.getUserProfile() + } + if (message is MessageRequestResponse) { + message.profile = storage.getUserProfile() + } + // Convert it to protobuf + val proto = message.toProto() ?: throw Error.ProtoConversionFailed + // Serialize the protobuf + val plaintext = PushTransportDetails.getPaddedMessageBody(proto.toByteArray()) + // Encrypt the serialized protobuf + val ciphertext = when (destination) { + is Destination.Contact -> MessageEncrypter.encrypt(plaintext, destination.publicKey) + is Destination.ClosedGroup -> { + val encryptionKeyPair = + MessagingModuleConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair( + destination.groupPublicKey + )!! + MessageEncrypter.encrypt(plaintext, encryptionKeyPair.hexEncodedPublicKey) + } + else -> throw IllegalStateException("Destination should not be open group.") + } + // Wrap the result + val kind: SignalServiceProtos.Envelope.Type + val senderPublicKey: String + when (destination) { + is Destination.Contact -> { + kind = SignalServiceProtos.Envelope.Type.SESSION_MESSAGE + senderPublicKey = "" + } + is Destination.ClosedGroup -> { + kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE + senderPublicKey = destination.groupPublicKey + } + else -> throw IllegalStateException("Destination should not be open group.") + } + val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext) + val base64EncodedData = Base64.encodeBytes(wrappedMessage) + // Send the result + return SnodeMessage( + message.recipient!!, + base64EncodedData, + ttl = getSpecifiedTtl(message, isSyncMessage) ?: message.ttl, + messageSendTime + ) + } + // One-on-One Chats & Closed Groups private fun sendToSnodeDestination(destination: Destination, message: Message, isSyncMessage: Boolean = false): Promise<Unit, Exception> { val deferred = deferred<Unit, Exception>() val promise = deferred.promise val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey() - // Set the timestamp, sender and recipient - if (message.sentTimestamp == null) { - message.sentTimestamp = System.currentTimeMillis() // Visible messages will already have their sent timestamp set - } - val messageSendTime = System.currentTimeMillis() + // recipient will be set later, so initialize it as a function here + val isSelfSend = { message.recipient == userPublicKey } - message.sender = userPublicKey - val isSelfSend = (message.recipient == userPublicKey) // Set the failure handler (need it here already for precondition failure handling) fun handleFailure(error: Exception) { - handleFailedMessageSend(message, error) - if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) { + handleFailedMessageSend(message, error, isSyncMessage) + if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend()) { SnodeModule.shared.broadcaster.broadcast("messageFailed", message.sentTimestamp!!) } deferred.reject(error) } try { - when (destination) { - is Destination.Contact -> message.recipient = destination.publicKey - is Destination.ClosedGroup -> message.recipient = destination.groupPublicKey - else -> throw IllegalStateException("Destination should not be an open group.") - } - // Validate the message - if (!message.isValid()) { throw Error.InvalidMessage } - // Stop here if this is a self-send, unless it's: - // • a configuration message - // • a sync message - // • a closed group control message of type `new` - var isNewClosedGroupControlMessage = false - if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) isNewClosedGroupControlMessage = true - if (isSelfSend && message !is ConfigurationMessage && !isSyncMessage && !isNewClosedGroupControlMessage && message !is UnsendRequest) { - handleSuccessfulMessageSend(message, destination) - deferred.resolve(Unit) - return promise - } - // Attach the user's profile if needed - if (message is VisibleMessage) { - val displayName = storage.getUserDisplayName()!! - val profileKey = storage.getUserProfileKey() - val profilePictureUrl = storage.getUserProfilePictureURL() - if (profileKey != null && profilePictureUrl != null) { - message.profile = Profile(displayName, profileKey, profilePictureUrl) - } else { - message.profile = Profile(displayName) - } - } - // Convert it to protobuf - val proto = message.toProto() ?: throw Error.ProtoConversionFailed - // Serialize the protobuf - val plaintext = PushTransportDetails.getPaddedMessageBody(proto.toByteArray()) - // Encrypt the serialized protobuf - val ciphertext = when (destination) { - is Destination.Contact -> MessageEncrypter.encrypt(plaintext, destination.publicKey) - is Destination.ClosedGroup -> { - val encryptionKeyPair = MessagingModuleConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair(destination.groupPublicKey)!! - MessageEncrypter.encrypt(plaintext, encryptionKeyPair.hexEncodedPublicKey) - } - else -> throw IllegalStateException("Destination should not be open group.") - } - // Wrap the result - val kind: SignalServiceProtos.Envelope.Type - val senderPublicKey: String + val snodeMessage = buildWrappedMessageToSnode(destination, message, isSyncMessage) // TODO: this might change in future for config messages val forkInfo = SnodeAPI.forkInfo 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) } - when (destination) { - is Destination.Contact -> { - kind = SignalServiceProtos.Envelope.Type.SESSION_MESSAGE - senderPublicKey = "" - } - is Destination.ClosedGroup -> { - kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE - senderPublicKey = destination.groupPublicKey - } - else -> throw IllegalStateException("Destination should not be open group.") - } - val wrappedMessage = MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext) - // Send the result - if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) { - SnodeModule.shared.broadcaster.broadcast("calculatingPoW", messageSendTime) - } - val base64EncodedData = Base64.encodeBytes(wrappedMessage) - // Send the result - val timestamp = messageSendTime + SnodeAPI.clockOffset - val snodeMessage = SnodeMessage(message.recipient!!, base64EncodedData, message.ttl, timestamp) - if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) { - SnodeModule.shared.broadcaster.broadcast("sendingMessage", messageSendTime) - } namespaces.map { namespace -> SnodeAPI.sendMessage(snodeMessage, requiresAuth = false, namespace = namespace) }.let { promises -> var isSuccess = false val promiseCount = promises.size @@ -183,13 +212,23 @@ object MessageSender { promise.success { if (isSuccess) { return@success } // Succeed as soon as the first promise succeeds isSuccess = true - if (destination is Destination.Contact && message is VisibleMessage && !isSelfSend) { - SnodeModule.shared.broadcaster.broadcast("messageSent", messageSendTime) - } val hash = it["hash"] as? String message.serverHash = hash handleSuccessfulMessageSend(message, destination, isSyncMessage) - val shouldNotify = ((message is VisibleMessage || message is UnsendRequest || message is CallMessage) && !isSyncMessage) + + val shouldNotify: Boolean = when (message) { + is VisibleMessage, is UnsendRequest -> !isSyncMessage + is CallMessage -> { + // Note: Other 'CallMessage' types are too big to send as push notifications + // so only send the 'preOffer' message as a notification + when (message.type) { + SignalServiceProtos.CallMessage.Type.PRE_OFFER -> true + else -> false + } + } + else -> false + } + /* if (message is ClosedGroupControlMessage && message.kind is ClosedGroupControlMessage.Kind.New) { shouldNotify = true @@ -214,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 = System.currentTimeMillis() + 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>() @@ -257,14 +316,7 @@ object MessageSender { try { // Attach the user's profile if needed if (message is VisibleMessage) { - val displayName = storage.getUserDisplayName()!! - val profileKey = storage.getUserProfileKey() - val profilePictureUrl = storage.getUserProfilePictureURL() - if (profileKey != null && profilePictureUrl != null) { - message.profile = Profile(displayName, profileKey, profilePictureUrl) - } else { - message.profile = Profile(displayName) - } + message.profile = storage.getUserProfile() } when (destination) { is Destination.OpenGroup -> { @@ -320,25 +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) { @@ -360,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) } @@ -375,16 +450,23 @@ object MessageSender { // • the destination was a contact // • we didn't sync it already if (destination is Destination.Contact && !isSyncMessage) { - if (message is VisibleMessage) { message.syncTarget = destination.publicKey } - if (message is ExpirationTimerUpdate) { message.syncTarget = destination.publicKey } + if (message is VisibleMessage) message.syncTarget = destination.publicKey + if (message is ExpirationTimerUpdate) message.syncTarget = destination.publicKey + + storage.markAsSyncing(timestamp, userPublicKey) sendToSnodeDestination(Destination.Contact(userPublicKey), message, true) } } - fun handleFailedMessageSend(message: Message, error: Exception) { + fun handleFailedMessageSend(message: Message, error: Exception, isSyncMessage: Boolean = false) { val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey()!! - storage.setErrorMessage(message.sentTimestamp!!, message.sender?:userPublicKey, error) + + val timestamp = message.sentTimestamp!! + val author = message.sender ?: userPublicKey + + if (isSyncMessage) storage.markAsSyncFailed(timestamp, author, error) + else storage.markAsSentFailed(timestamp, author, error) } // Convenience @@ -398,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) } } @@ -408,29 +490,30 @@ object MessageSender { @JvmStatic fun send(message: Message, address: Address) { - val threadID = MessagingModuleConfiguration.shared.storage.getOrCreateThreadIdFor(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) JobQueue.shared.add(job) } - fun sendNonDurably(message: VisibleMessage, attachments: List<SignalAttachment>, address: Address): Promise<Unit, Exception> { + fun sendNonDurably(message: VisibleMessage, attachments: List<SignalAttachment>, address: Address, isSyncMessage: Boolean): Promise<Unit, Exception> { val attachmentIDs = MessagingModuleConfiguration.shared.messageDataProvider.getAttachmentIDsFor(message.id!!) message.attachmentIDs.addAll(attachmentIDs) - return sendNonDurably(message, address) + return sendNonDurably(message, address, isSyncMessage) } - fun sendNonDurably(message: Message, address: Address): Promise<Unit, Exception> { - val threadID = MessagingModuleConfiguration.shared.storage.getOrCreateThreadIdFor(address) + fun sendNonDurably(message: Message, address: Address, isSyncMessage: Boolean): Promise<Unit, Exception> { + val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) message.threadID = threadID val destination = Destination.from(address) - return send(message, destination) + return send(message, destination, isSyncMessage) } // 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 906e201a8a..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,31 +8,36 @@ 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.utilities.guava.Optional import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.Hex +import org.session.libsignal.utilities.Log +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 org.session.libsignal.utilities.Hex -import org.session.libsignal.utilities.ThreadUtils -import org.session.libsignal.utilities.Log -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 @@ -48,32 +53,47 @@ 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) }), - null, null, LinkedList(admins.map { Address.fromSerialized(it) }), System.currentTimeMillis()) + 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) + // Send a closed group update message to all members individually val closedGroupUpdateKind = ClosedGroupControlMessage.Kind.New(ByteString.copyFrom(Hex.fromStringCondensed(groupPublicKey)), name, encryptionKeyPair, membersAsData, adminsAsData, 0) - val sentTime = System.currentTimeMillis() + val sentTime = SnodeAPI.nowWithOffset + + // Add the group to the user's set of public keys to poll for + storage.addClosedGroupPublicKey(groupPublicKey) + // Store the encryption key pair + storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, sentTime) + // Create the thread + storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) + + // Notify the user + val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) + storage.insertOutgoingInfoMessage(context, groupID, SignalServiceGroup.Type.CREATION, name, members, admins, threadID, sentTime) + + 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)).get() + sendNonDurably(closedGroupControlMessage, Address.fromSerialized(member), member == ourPubKey).get() } catch (e: Exception) { + // We failed to properly create the group so delete it's associated data (in the past + // we didn't create this data until the messages successfully sent but this resulted + // in race conditions due to the `NEW` message sent to our own swarm) + storage.removeClosedGroupPublicKey(groupPublicKey) + storage.removeAllClosedGroupEncryptionKeyPairs(groupPublicKey) + storage.deleteConversation(threadID) deferred.reject(e) return@queue } } - // Add the group to the user's set of public keys to poll for - storage.addClosedGroupPublicKey(groupPublicKey) - // Store the encryption key pair - storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey) - // Notify the user - val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) - storage.insertOutgoingInfoMessage(context, groupID, SignalServiceGroup.Type.CREATION, name, members, admins, threadID, sentTime) + // Add the group to the config now that it was successfully created + 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 @@ -83,24 +103,6 @@ fun MessageSender.create(name: String, members: Collection<String>): Promise<Str return deferred.promise } -fun MessageSender.update(groupPublicKey: String, members: List<String>, name: String) { - val context = MessagingModuleConfiguration.shared.context - val storage = MessagingModuleConfiguration.shared.storage - val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) - val group = storage.getGroup(groupID) ?: run { - Log.d("Loki", "Can't update nonexistent closed group.") - throw Error.NoThread - } - // Update name if needed - if (name != group.title) { setName(groupPublicKey, name) } - // Add members if needed - val addedMembers = members - group.members.map { it.serialize() } - if (!addedMembers.isEmpty()) { addMembers(groupPublicKey, addedMembers) } - // Remove members if needed - val removedMembers = group.members.map { it.serialize() } - members - if (removedMembers.isEmpty()) { removeMembers(groupPublicKey, removedMembers) } -} - fun MessageSender.setName(groupPublicKey: String, newName: String) { val context = MessagingModuleConfiguration.shared.context val storage = MessagingModuleConfiguration.shared.storage @@ -113,8 +115,8 @@ fun MessageSender.setName(groupPublicKey: String, newName: String) { val admins = group.admins.map { it.serialize() } // Send the update to the group val kind = ClosedGroupControlMessage.Kind.NameChange(newName) - val sentTime = System.currentTimeMillis() - val closedGroupControlMessage = ClosedGroupControlMessage(kind) + val sentTime = SnodeAPI.nowWithOffset + val closedGroupControlMessage = ClosedGroupControlMessage(kind, groupID) closedGroupControlMessage.sentTimestamp = sentTime send(closedGroupControlMessage, Address.fromSerialized(groupID)) // Update the group @@ -133,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 @@ -153,21 +155,28 @@ fun MessageSender.addMembers(groupPublicKey: String, membersToAdd: List<String>) val name = group.title // Send the update to the group val memberUpdateKind = ClosedGroupControlMessage.Kind.MembersAdded(newMembersAsData) - val sentTime = System.currentTimeMillis() - val closedGroupControlMessage = ClosedGroupControlMessage(memberUpdateKind) + val sentTime = SnodeAPI.nowWithOffset + 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 // updates from before that timestamp. By setting the timestamp of the message below to a value // greater than that of the `MembersAdded` message, we ensure that newly added members ignore // the `MembersAdded` message. - closedGroupControlMessage.sentTimestamp = System.currentTimeMillis() + closedGroupControlMessage.sentTimestamp = SnodeAPI.nowWithOffset send(closedGroupControlMessage, Address.fromSerialized(member)) } // Notify the user @@ -208,8 +217,8 @@ fun MessageSender.removeMembers(groupPublicKey: String, membersToRemove: List<St val name = group.title // Send the update to the group val memberUpdateKind = ClosedGroupControlMessage.Kind.MembersRemoved(removeMembersAsData) - val sentTime = System.currentTimeMillis() - val closedGroupControlMessage = ClosedGroupControlMessage(memberUpdateKind) + val sentTime = SnodeAPI.nowWithOffset + val closedGroupControlMessage = ClosedGroupControlMessage(memberUpdateKind, groupID) closedGroupControlMessage.sentTimestamp = sentTime send(closedGroupControlMessage, Address.fromSerialized(groupID)) // Send the new encryption key pair to the remaining group members. @@ -238,19 +247,19 @@ 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 sentTime = System.currentTimeMillis() + val closedGroupControlMessage = ClosedGroupControlMessage(ClosedGroupControlMessage.Kind.MemberLeft(), groupID) + val sentTime = SnodeAPI.nowWithOffset closedGroupControlMessage.sentTimestamp = sentTime storage.setActive(groupID, false) - sendNonDurably(closedGroupControlMessage, Address.fromSerialized(groupID)).success { + sendNonDurably(closedGroupControlMessage, Address.fromSerialized(groupID), isSyncMessage = false).success { // Notify the user val infoType = SignalServiceGroup.Type.QUIT - val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) if (notifyUser) { + val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) storage.insertOutgoingInfoMessage(context, groupID, infoType, name, updatedMembers, admins, threadID, sentTime) } // Remove the group private key and unsubscribe from PNs - MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey) + MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey, true) deferred.resolve(Unit) }.fail { storage.setActive(groupID, true) @@ -282,7 +291,7 @@ fun MessageSender.generateAndSendNewEncryptionKeyPair(groupPublicKey: String, ta // Distribute it sendEncryptionKeyPair(groupPublicKey, newKeyPair, targetMembers)?.success { // Store it * after * having sent out the message to the group - storage.addClosedGroupEncryptionKeyPair(newKeyPair, groupPublicKey) + storage.addClosedGroupEncryptionKeyPair(newKeyPair, groupPublicKey, SnodeAPI.nowWithOffset) pendingKeyPairs[groupPublicKey] = Optional.absent() } } @@ -298,11 +307,12 @@ fun MessageSender.sendEncryptionKeyPair(groupPublicKey: String, newKeyPair: ECKe ClosedGroupControlMessage.KeyPairWrapper(publicKey, ByteString.copyFrom(ciphertext)) } val kind = ClosedGroupControlMessage.Kind.EncryptionKeyPair(ByteString.copyFrom(Hex.fromStringCondensed(groupPublicKey)), wrappers) - val sentTime = System.currentTimeMillis() - val closedGroupControlMessage = ClosedGroupControlMessage(kind) + val sentTime = SnodeAPI.nowWithOffset + val closedGroupControlMessage = ClosedGroupControlMessage(kind, null) closedGroupControlMessage.sentTimestamp = sentTime return if (force) { - MessageSender.sendNonDurably(closedGroupControlMessage, Address.fromSerialized(destination)) + val isSync = MessagingModuleConfiguration.shared.storage.getUserPublicKey() == destination + MessageSender.sendNonDurably(closedGroupControlMessage, Address.fromSerialized(destination), isSyncMessage = isSync) } else { MessageSender.send(closedGroupControlMessage, Address.fromSerialized(destination)) null @@ -333,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 86b856d952..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 @@ -1,10 +1,14 @@ 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 @@ -22,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 @@ -30,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 @@ -41,6 +47,7 @@ import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceGroup import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log @@ -57,7 +64,10 @@ internal fun MessageReceiver.isBlocked(publicKey: String): Boolean { return recipient.isBlocked } -fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, openGroupID: String?) { +fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, threadId: Long, openGroupID: String?) { + // Do nothing if the message was outdated + if (MessageReceiver.messageIsOutdated(message, threadId, openGroupID)) { return } + when (message) { is ReadReceipt -> handleReadReceipt(message) is TypingIndicator -> handleTypingIndicator(message) @@ -67,8 +77,8 @@ fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, is ConfigurationMessage -> handleConfigurationMessage(message) is UnsendRequest -> handleUnsendRequest(message) is MessageRequestResponse -> handleMessageRequestResponse(message) - is VisibleMessage -> handleVisibleMessage(message, proto, openGroupID, - runIncrement = true, + is VisibleMessage -> handleVisibleMessage( + message, proto, openGroupID, threadId, runThreadUpdate = true, runProfileUpdate = true ) @@ -76,6 +86,33 @@ fun MessageReceiver.handle(message: Message, proto: SignalServiceProtos.Content, } } +fun MessageReceiver.messageIsOutdated(message: Message, threadId: Long, openGroupID: String?): Boolean { + when (message) { + is ReadReceipt -> return false // No visible artifact created so better to keep for more reliable read states + is UnsendRequest -> return false // We should always process the removal of messages just in case + } + + // Determine the state of the conversation and the validity of the message + val storage = MessagingModuleConfiguration.shared.storage + val userPublicKey = storage.getUserPublicKey()!! + val threadRecipient = storage.getRecipientForThread(threadId) + val conversationVisibleInConfig = storage.conversationInConfig( + if (message.groupPublicKey == null) threadRecipient?.address?.serialize() else null, + message.groupPublicKey, + openGroupID, + true + ) + val canPerformChange = storage.canPerformConfigChange( + if (threadRecipient?.address?.serialize() == userPublicKey) SharedConfigMessage.Kind.USER_PROFILE.name else SharedConfigMessage.Kind.CONTACTS.name, + userPublicKey, + message.sentTimestamp!! + ) + + // If the thread is visible or the message was sent more recently than the last config message (minus + // buffer period) then we should process the message, if not then the message is outdated + return (!conversationVisibleInConfig && !canPerformChange) +} + // region Control Messages private fun MessageReceiver.handleReadReceipt(message: ReadReceipt) { val context = MessagingModuleConfiguration.shared.context @@ -116,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.") } } @@ -128,6 +182,7 @@ private fun MessageReceiver.handleDataExtractionNotification(message: DataExtrac if (message.groupPublicKey != null) return val storage = MessagingModuleConfiguration.shared.storage val senderPublicKey = message.sender!! + val notification: DataExtractionNotificationInfoMessage = when(message.kind) { is DataExtractionNotification.Kind.Screenshot -> DataExtractionNotificationInfoMessage(DataExtractionNotificationInfoMessage.Kind.SCREENSHOT) is DataExtractionNotification.Kind.MediaSaved -> DataExtractionNotificationInfoMessage(DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED) @@ -148,15 +203,21 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { TextSecurePreferences.setConfigurationMessageSynced(context, true) TextSecurePreferences.setLastProfileUpdateTime(context, message.sentTimestamp!!) + val isForceSync = TextSecurePreferences.hasForcedNewConfig(context) + val currentTime = SnodeAPI.nowWithOffset + if (ConfigBase.isNewConfigEnabled(isForceSync, currentTime)) { + TextSecurePreferences.setHasLegacyConfig(context, true) + if (!firstTimeSync) return + } val allClosedGroupPublicKeys = storage.getAllClosedGroupPublicKeys() for (closedGroup in message.closedGroups) { 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) - } else if (firstTimeSync) { + storage.addClosedGroupEncryptionKeyPair(closedGroup.encryptionKeyPair!!, closedGroup.publicKey, message.sentTimestamp!!) + } 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 } @@ -165,9 +226,9 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { .replace(OpenGroupApi.httpDefaultServer, OpenGroupApi.defaultServer) }) { if (allV2OpenGroups.contains(openGroup)) continue - Log.d("OpenGroup", "All open groups doesn't contain $openGroup") + Log.d("OpenGroup", "All open groups doesn't contain open group") if (!storage.hasBackgroundGroupAddJob(openGroup)) { - Log.d("OpenGroup", "Doesn't contain background job for $openGroup, adding") + Log.d("OpenGroup", "Doesn't contain background job for open group, adding") JobQueue.shared.add(BackgroundGroupAddJob(openGroup)) } } @@ -181,30 +242,29 @@ private fun handleConfigurationMessage(message: ConfigurationMessage) { && TextSecurePreferences.getProfilePictureURL(context) != message.profilePicture) { val profileKey = Base64.encodeBytes(message.profileKey) ProfileKeyUtil.setEncodedProfileKey(context, profileKey) - profileManager.setProfileKey(context, recipient, message.profileKey) - if (!message.profilePicture.isNullOrEmpty() && TextSecurePreferences.getProfilePictureURL(context) != message.profilePicture) { - storage.setUserProfilePictureURL(message.profilePicture!!) - } + profileManager.setProfilePicture(context, recipient, message.profilePicture, message.profileKey) } storage.addContacts(message.contacts) } -fun MessageReceiver.handleUnsendRequest(message: UnsendRequest) { +fun MessageReceiver.handleUnsendRequest(message: UnsendRequest): Long? { val userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey() - if (message.sender != message.author && (message.sender != userPublicKey && userPublicKey != null)) { return } + if (message.sender != message.author && (message.sender != userPublicKey && userPublicKey != null)) { return null } val context = MessagingModuleConfiguration.shared.context val storage = MessagingModuleConfiguration.shared.storage val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider - val timestamp = message.timestamp ?: return - val author = message.author ?: return - val messageIdToDelete = storage.getMessageIdInDatabase(timestamp, author) ?: return - messageDataProvider.getServerHashForMessage(messageIdToDelete)?.let { serverHash -> + val timestamp = message.timestamp ?: return null + val author = message.author ?: return null + val (messageIdToDelete, mms) = storage.getMessageIdInDatabase(timestamp, author) ?: return null + messageDataProvider.getServerHashForMessage(messageIdToDelete, mms)?.let { serverHash -> SnodeAPI.deleteMessage(author, listOf(serverHash)) } - messageDataProvider.updateMessageAsDeleted(timestamp, author) - if (!messageDataProvider.isOutgoingMessage(messageIdToDelete)) { + val deletedMessageId = messageDataProvider.updateMessageAsDeleted(timestamp, author) + if (!messageDataProvider.isOutgoingMessage(timestamp)) { SSKEnvironment.shared.notificationManager.updateNotification(context) } + + return deletedMessageId } fun handleMessageRequestResponse(message: MessageRequestResponse) { @@ -212,24 +272,37 @@ fun handleMessageRequestResponse(message: MessageRequestResponse) { } //endregion -fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, - proto: SignalServiceProtos.Content, - openGroupID: String?, - runIncrement: Boolean, - runThreadUpdate: Boolean, - runProfileUpdate: Boolean): Long? { +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, + openGroupID: String?, + threadId: Long, + runThreadUpdate: Boolean, + runProfileUpdate: Boolean +): 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 + + // Do nothing if the message was outdated + if (MessageReceiver.messageIsOutdated(message, threadId, openGroupID)) { return null } + // Get or create thread // FIXME: In case this is an open group this actually * doesn't * create the thread if it doesn't yet // exist. This is intentional, but it's very non-obvious. - val threadID = storage.getOrCreateThreadIdFor(message.syncTarget ?: messageSender!!, message.groupPublicKey, openGroupID) - if (threadID < 0) { + val threadID = storage.getThreadIdFor(message.syncTarget ?: messageSender!!, message.groupPublicKey, openGroupID, createThread = true) // Thread doesn't exist; should only be reached in a case where we are processing open group messages for a no longer existent thread - throw MessageReceiver.Error.NoThread - } + ?: throw MessageReceiver.Error.NoThread val threadRecipient = storage.getRecipientForThread(threadID) val userBlindedKey = openGroupID?.let { val openGroup = storage.getOpenGroup(threadID) ?: return@let null @@ -256,14 +329,31 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, val profileKeyChanged = (recipient.profileKey == null || !MessageDigest.isEqual(recipient.profileKey, newProfileKey)) if ((profileKeyValid && profileKeyChanged) || (profileKeyValid && needsProfilePicture)) { - profileManager.setProfileKey(context, recipient, newProfileKey!!) + profileManager.setProfilePicture(context, recipient, profile.profilePictureURL, newProfileKey) profileManager.setUnidentifiedAccessMode(context, recipient, Recipient.UnidentifiedAccessMode.UNKNOWN) - profileManager.setProfilePictureURL(context, recipient, profile.profilePictureURL!!) + } else if (newProfileKey == null || newProfileKey.isEmpty() || profile.profilePictureURL.isNullOrEmpty()) { + 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 + var quoteMessageBody: String? = null if (message.quote != null && proto.dataMessage.hasQuote()) { val quote = proto.dataMessage.quote @@ -275,6 +365,7 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val messageInfo = messageDataProvider.getMessageForQuote(quote.id, author) + quoteMessageBody = messageInfo?.third quoteModel = if (messageInfo != null) { val attachments = if (messageInfo.second) messageDataProvider.getAttachmentsAndLinkPreviewFor(messageInfo.first) else ArrayList() QuoteModel(quote.id, author,null,false, attachments) @@ -299,14 +390,9 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, } } // 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 val threadIsGroup = threadRecipient?.isGroupRecipient == true message.reaction?.let { reaction -> @@ -319,21 +405,25 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, storage.removeReaction(reaction.emoji!!, reaction.timestamp!!, reaction.publicKey!!, threadIsGroup) } } ?: run { + // A user is mentioned if their public key is in the body of a message or one of their messages + // was quoted + val messageText = message.text + message.hasMention = listOf(userPublicKey, userBlindedKey) + .filterNotNull() + .any { 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, runIncrement, 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 } - // Cancel any typing indicators if needed - cancelTypingIndicatorsIfNeeded(message.sender!!) return null } @@ -418,26 +508,65 @@ private fun MessageReceiver.handleClosedGroupControlMessage(message: ClosedGroup is ClosedGroupControlMessage.Kind.MembersRemoved -> handleClosedGroupMembersRemoved(message) is ClosedGroupControlMessage.Kind.MemberLeft -> handleClosedGroupMemberLeft(message) } + if ( + message.kind !is ClosedGroupControlMessage.Kind.New && + MessagingModuleConfiguration.shared.storage.canPerformConfigChange( + SharedConfigMessage.Kind.GROUPS.name, + MessagingModuleConfiguration.shared.storage.getUserPublicKey()!!, + message.sentTimestamp!! + ) + ) { + // update the config + val closedGroupPublicKey = message.getPublicKey() + val storage = MessagingModuleConfiguration.shared.storage + storage.updateGroupConfig(closedGroupPublicKey) + } } +private fun ClosedGroupControlMessage.getPublicKey(): String = kind!!.let { when (it) { + is ClosedGroupControlMessage.Kind.New -> it.publicKey.toByteArray().toHexString() + is ClosedGroupControlMessage.Kind.EncryptionKeyPair -> it.publicKey?.toByteArray()?.toHexString() ?: groupPublicKey!! + is ClosedGroupControlMessage.Kind.MemberLeft -> groupPublicKey!! + is ClosedGroupControlMessage.Kind.MembersAdded -> groupPublicKey!! + is ClosedGroupControlMessage.Kind.MembersRemoved -> groupPublicKey!! + is ClosedGroupControlMessage.Kind.NameChange -> groupPublicKey!! +}} + 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) return + 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 = TextSecurePreferences.getLocalNumber(context) - // Create the group + val userPublicKey = storage.getUserPublicKey()!! val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) val groupExists = storage.getGroup(groupID) != null + + if (!storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey, sentTimestamp)) { + // If the closed group already exists then store the encryption keys (since the config only stores + // the latest key we won't be able to decrypt older messages if we were added to the group within + // the last two weeks and the key has been rotated - unfortunately if the user was added more than + // two weeks ago and the keys were rotated within the last two weeks then we won't be able to decrypt + // messages received before the key rotation) + if (groupExists) { + storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, sentTimestamp) + storage.updateGroupConfig(groupPublicKey) + } + return + } + + // Create the group if (groupExists) { // Update the group if (!storage.isGroupActive(groupPublicKey)) { @@ -449,18 +578,17 @@ 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) // Add the group to the user's set of public keys to poll for storage.addClosedGroupPublicKey(groupPublicKey) // Store the encryption key pair - storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey) - // Set expiration timer - storage.setExpirationTimer(groupID, expireTimer) + storage.addClosedGroupEncryptionKeyPair(encryptionKeyPair, groupPublicKey, sentTimestamp) + storage.createInitialConfigGroup(groupPublicKey, name, GroupUtil.createConfigMemberMap(members, admins), formationTimestamp, encryptionKeyPair, expirationTimer) // Notify the PN server - PushNotificationAPI.performOperation(PushNotificationAPI.ClosedGroupOperation.Subscribe, groupPublicKey, storage.getUserPublicKey()!!) + PushRegistryV1.register(device = MessagingModuleConfiguration.shared.device, publicKey = userPublicKey) // Notify the user if (userPublicKey == sender && !groupExists) { val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) @@ -508,7 +636,7 @@ private fun MessageReceiver.handleClosedGroupEncryptionKeyPair(message: ClosedGr Log.d("Loki", "Ignoring duplicate closed group encryption key pair.") return } - storage.addClosedGroupEncryptionKeyPair(keyPair, groupPublicKey) + storage.addClosedGroupEncryptionKeyPair(keyPair, groupPublicKey, message.sentTimestamp!!) Log.d("Loki", "Received a new closed group encryption key pair.") } @@ -536,7 +664,12 @@ private fun MessageReceiver.handleClosedGroupNameChanged(message: ClosedGroupCon val members = group.members.map { it.serialize() } val admins = group.admins.map { it.serialize() } val name = kind.name - storage.updateTitle(groupID, name) + + // Only update the group in storage if it isn't invalidated by the config state + if (storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey!!, message.sentTimestamp!!)) { + storage.updateTitle(groupID, name) + } + // Notify the user if (userPublicKey == senderPublicKey) { val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) @@ -570,12 +703,16 @@ private fun MessageReceiver.handleClosedGroupMembersAdded(message: ClosedGroupCo val updateMembers = kind.members.map { it.toByteArray().toHexString() } val newMembers = members + updateMembers - storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) }) - // Update zombie members in case the added members are zombies - val zombies = storage.getZombieMembers(groupID) - if (zombies.intersect(updateMembers).isNotEmpty()) { - storage.setZombieMembers(groupID, zombies.minus(updateMembers).map { Address.fromSerialized(it) }) + // Only update the group in storage if it isn't invalidated by the config state + if (storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey, message.sentTimestamp!!)) { + storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) }) + + // Update zombie members in case the added members are zombies + val zombies = storage.getZombieMembers(groupID) + if (zombies.intersect(updateMembers).isNotEmpty()) { + storage.setZombieMembers(groupID, zombies.minus(updateMembers).map { Address.fromSerialized(it) }) + } } // Notify the user @@ -657,13 +794,18 @@ private fun MessageReceiver.handleClosedGroupMembersRemoved(message: ClosedGroup Log.d("Loki", "Received a MEMBERS_REMOVED instead of a MEMBERS_LEFT from sender: $senderPublicKey.") } val wasCurrentUserRemoved = userPublicKey in removedMembers - // Admin should send a MEMBERS_LEFT message but handled here just in case - if (didAdminLeave || wasCurrentUserRemoved) { - disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey) - } else { - storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) }) - // Update zombie members - storage.setZombieMembers(groupID, zombies.minus(removedMembers).map { Address.fromSerialized(it) }) + + // Only update the group in storage if it isn't invalidated by the config state + if (storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey, message.sentTimestamp!!)) { + // Admin should send a MEMBERS_LEFT message but handled here just in case + if (didAdminLeave || wasCurrentUserRemoved) { + disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey, true) + return + } else { + storage.updateMembers(groupID, newMembers.map { Address.fromSerialized(it) }) + // Update zombie members + storage.setZombieMembers(groupID, zombies.minus(removedMembers).map { Address.fromSerialized(it) }) + } } // Notify the user @@ -712,24 +854,30 @@ private fun MessageReceiver.handleClosedGroupMemberLeft(message: ClosedGroupCont val didAdminLeave = admins.contains(senderPublicKey) val updatedMemberList = members - senderPublicKey val userLeft = (userPublicKey == senderPublicKey) - if (didAdminLeave || userLeft) { - disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey) - } else { - storage.updateMembers(groupID, updatedMemberList.map { Address.fromSerialized(it) }) - // Update zombie members - val zombies = storage.getZombieMembers(groupID) - storage.setZombieMembers(groupID, zombies.plus(senderPublicKey).map { Address.fromSerialized(it) }) + + // Only update the group in storage if it isn't invalidated by the config state + if (storage.canPerformConfigChange(SharedConfigMessage.Kind.GROUPS.name, userPublicKey, message.sentTimestamp!!)) { + if (didAdminLeave || userLeft) { + disableLocalGroupAndUnsubscribe(groupPublicKey, groupID, userPublicKey, delete = userLeft) + + if (userLeft) { + return + } + } else { + storage.updateMembers(groupID, updatedMemberList.map { Address.fromSerialized(it) }) + // Update zombie members + val zombies = storage.getZombieMembers(groupID) + storage.setZombieMembers(groupID, zombies.plus(senderPublicKey).map { Address.fromSerialized(it) }) + } } + // Notify the user - if (userLeft) { - val threadID = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) - storage.insertOutgoingInfoMessage(context, groupID, SignalServiceGroup.Type.QUIT, name, members, admins, threadID, message.sentTimestamp!!) - } else { + if (!userLeft) { storage.insertIncomingInfoMessage(context, senderPublicKey, groupID, SignalServiceGroup.Type.QUIT, name, members, admins, message.sentTimestamp!!) } } -private fun isValidGroupUpdate(group: GroupRecord, sentTimestamp: Long, senderPublicKey: String): Boolean { +private fun isValidGroupUpdate(group: GroupRecord, sentTimestamp: Long, senderPublicKey: String): Boolean { val oldMembers = group.members.map { it.serialize() } // Check that the message isn't from before the group was created if (group.formationTimestamp > sentTimestamp) { @@ -744,7 +892,7 @@ private fun isValidGroupUpdate(group: GroupRecord, sentTimestamp: Long, senderPu return true } -fun MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey: String, groupID: String, userPublicKey: String) { +fun MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey: String, groupID: String, userPublicKey: String, delete: Boolean) { val storage = MessagingModuleConfiguration.shared.storage storage.removeClosedGroupPublicKey(groupPublicKey) // Remove the key pairs @@ -753,8 +901,14 @@ 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) + + if (delete) { + val threadId = storage.getOrCreateThreadIdFor(Address.fromSerialized(groupID)) + storage.cancelPendingMessageSendJobs(threadId) + storage.deleteConversation(threadId) + } } // endregion diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/link_preview/LinkPreview.java b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/link_preview/LinkPreview.java index 8aa8102a60..b2b7cfc7d4 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/link_preview/LinkPreview.java +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/link_preview/LinkPreview.java @@ -13,6 +13,7 @@ import org.session.libsignal.utilities.JsonUtil; import org.session.libsignal.utilities.guava.Optional; import java.io.IOException; +import java.util.Objects; public class LinkPreview { @@ -75,4 +76,17 @@ public class LinkPreview { public static LinkPreview deserialize(@NonNull String serialized) throws IOException { return JsonUtil.fromJson(serialized, LinkPreview.class); } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LinkPreview that = (LinkPreview) o; + return Objects.equals(url, that.url) && Objects.equals(title, that.title) && Objects.equals(attachmentId, that.attachmentId) && Objects.equals(thumbnail, that.thumbnail); + } + + @Override + public int hashCode() { + return Objects.hash(url, title, attachmentId, thumbnail); + } } 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/ClosedGroupPollerV2.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt index 140bf6b9ed..293fbc8d8a 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt @@ -54,10 +54,9 @@ class ClosedGroupPollerV2 { setUpPolling(groupPublicKey) } - fun stop() { - val storage = MessagingModuleConfiguration.shared.storage - val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys() - allGroupPublicKeys.iterator().forEach { stopPolling(it) } + fun stopAll() { + futures.forEach { it.value.cancel(false) } + isPolling.forEach { isPolling[it.key] = false } } fun stopPolling(groupPublicKey: String) { @@ -80,7 +79,12 @@ class ClosedGroupPollerV2 { // reasonable fake time interval to use instead. val storage = MessagingModuleConfiguration.shared.storage val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) - val threadID = storage.getThreadId(groupID) ?: return + val threadID = storage.getThreadId(groupID) + if (threadID == null) { + Log.d("Loki", "Stopping group poller due to missing thread for closed group: $groupPublicKey.") + stopPolling(groupPublicKey) + return + } val lastUpdated = storage.getLastUpdated(threadID) val timeSinceLastMessage = if (lastUpdated != -1L) Date().time - lastUpdated else 5 * 60 * 1000 val minPollInterval = Companion.minPollInterval diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt index 145155e97c..b9baadcaba 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -12,6 +12,7 @@ import org.session.libsession.messaging.jobs.MessageReceiveJob import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.messaging.jobs.OpenGroupDeleteJob import org.session.libsession.messaging.jobs.TrimThreadJob +import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.Endpoint @@ -30,6 +31,7 @@ import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.successBackground +import java.util.UUID import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit @@ -39,15 +41,101 @@ class OpenGroupPoller(private val server: String, private val executorService: S var isCaughtUp = false var secondToLastJob: MessageReceiveJob? = null private var future: ScheduledFuture<*>? = null + @Volatile private var runId: UUID = UUID.randomUUID() companion object { private const val pollInterval: Long = 4000L const val maxInactivityPeriod = 14 * 24 * 60 * 60 * 1000 + + public fun handleRoomPollInfo( + server: String, + roomToken: String, + pollInfo: OpenGroupApi.RoomPollInfo, + createGroupIfMissingWithPublicKey: String? = null + ) { + val storage = MessagingModuleConfiguration.shared.storage + val groupId = "$server.$roomToken" + val dbGroupId = GroupUtil.getEncodedOpenGroupID(groupId.toByteArray()) + val existingOpenGroup = storage.getOpenGroup(roomToken, server) + + // If we don't have an existing group and don't have a 'createGroupIfMissingWithPublicKey' + // value then don't process the poll info + val publicKey = existingOpenGroup?.publicKey ?: createGroupIfMissingWithPublicKey + val name = pollInfo.details?.name ?: existingOpenGroup?.name + val infoUpdates = pollInfo.details?.infoUpdates ?: existingOpenGroup?.infoUpdates + + if (publicKey == null) return + + val openGroup = OpenGroup( + server = server, + room = pollInfo.token, + name = name ?: "", + publicKey = publicKey, + imageId = (pollInfo.details?.imageId ?: existingOpenGroup?.imageId), + canWrite = pollInfo.write, + infoUpdates = infoUpdates ?: 0 + ) + // - Open Group changes + storage.updateOpenGroup(openGroup) + + // - User Count + storage.setUserCount(roomToken, server, pollInfo.activeUsers) + + // - Moderators + pollInfo.details?.moderators?.let { moderatorList -> + storage.setGroupMemberRoles(moderatorList.map { + GroupMember(groupId, it, GroupMemberRole.MODERATOR) + }) + } + pollInfo.details?.hiddenModerators?.let { moderatorList -> + storage.setGroupMemberRoles(moderatorList.map { + GroupMember(groupId, it, GroupMemberRole.HIDDEN_MODERATOR) + }) + } + // - Admins + pollInfo.details?.admins?.let { moderatorList -> + storage.setGroupMemberRoles(moderatorList.map { + GroupMember(groupId, it, GroupMemberRole.ADMIN) + }) + } + pollInfo.details?.hiddenAdmins?.let { moderatorList -> + storage.setGroupMemberRoles(moderatorList.map { + GroupMember(groupId, it, GroupMemberRole.HIDDEN_ADMIN) + }) + } + + // Update the group avatar + if ( + ( + pollInfo.details != null && + pollInfo.details.imageId != null && ( + pollInfo.details.imageId != existingOpenGroup?.imageId || + !storage.hasDownloadedProfilePicture(dbGroupId) + ) && + storage.getGroupAvatarDownloadJob(openGroup.server, openGroup.room, pollInfo.details.imageId) == null + ) || ( + pollInfo.details == null && + existingOpenGroup?.imageId != null && + !storage.hasDownloadedProfilePicture(dbGroupId) && + storage.getGroupAvatarDownloadJob(openGroup.server, openGroup.room, existingOpenGroup.imageId) == null + ) + ) { + JobQueue.shared.add(GroupAvatarDownloadJob(server, roomToken, openGroup.imageId)) + } + else if ( + pollInfo.details != null && + pollInfo.details.imageId == null && + existingOpenGroup?.imageId != null + ) { + storage.removeProfilePicture(dbGroupId) + } + } } fun startIfNeeded() { if (hasStarted) { return } hasStarted = true + runId = UUID.randomUUID() future = executorService?.schedule(::poll, 0, TimeUnit.MILLISECONDS) } @@ -57,9 +145,10 @@ class OpenGroupPoller(private val server: String, private val executorService: S } fun poll(isPostCapabilitiesRetry: Boolean = false): Promise<Unit, Exception> { + val currentRunId = runId val storage = MessagingModuleConfiguration.shared.storage val rooms = storage.getAllOpenGroups().values.filter { it.server == server }.map { it.room } - rooms.forEach { downloadGroupAvatarIfNeeded(it) } + return OpenGroupApi.poll(rooms, server).successBackground { responses -> responses.filterNot { it.body == null }.forEach { response -> when (response.endpoint) { @@ -81,27 +170,36 @@ class OpenGroupPoller(private val server: String, private val executorService: S is Endpoint.Outbox, is Endpoint.OutboxSince -> { handleDirectMessages(server, true, response.body as List<OpenGroupApi.DirectMessage>) } + else -> { /* We don't care about the result of any other calls (won't be polled for) */} } if (secondToLastJob == null && !isCaughtUp) { isCaughtUp = true } } - executorService?.schedule(this@OpenGroupPoller::poll, pollInterval, TimeUnit.MILLISECONDS) + + // Only poll again if it's the same poller run + if (currentRunId == runId) { + future = executorService?.schedule(this@OpenGroupPoller::poll, pollInterval, TimeUnit.MILLISECONDS) + } }.fail { - updateCapabilitiesIfNeeded(isPostCapabilitiesRetry, it) + updateCapabilitiesIfNeeded(isPostCapabilitiesRetry, currentRunId, it) }.map { } } - private fun updateCapabilitiesIfNeeded(isPostCapabilitiesRetry: Boolean, exception: Exception) { + private fun updateCapabilitiesIfNeeded(isPostCapabilitiesRetry: Boolean, currentRunId: UUID, exception: Exception) { if (exception is OnionRequestAPI.HTTPRequestFailedBlindingRequiredException) { if (!isPostCapabilitiesRetry) { OpenGroupApi.getCapabilities(server).map { handleCapabilities(server, it) } - executorService?.schedule({ poll(isPostCapabilitiesRetry = true) }, pollInterval, TimeUnit.MILLISECONDS) + + // Only poll again if it's the same poller run + if (currentRunId == runId) { + future = executorService?.schedule({ poll(isPostCapabilitiesRetry = true) }, pollInterval, TimeUnit.MILLISECONDS) + } } - } else { - executorService?.schedule(this@OpenGroupPoller::poll, pollInterval, TimeUnit.MILLISECONDS) + } else if (currentRunId == runId) { + future = executorService?.schedule(this@OpenGroupPoller::poll, pollInterval, TimeUnit.MILLISECONDS) } } @@ -109,54 +207,7 @@ class OpenGroupPoller(private val server: String, private val executorService: S val storage = MessagingModuleConfiguration.shared.storage storage.setServerCapabilities(server, capabilities.capabilities) } - - private fun handleRoomPollInfo( - server: String, - roomToken: String, - pollInfo: OpenGroupApi.RoomPollInfo - ) { - val storage = MessagingModuleConfiguration.shared.storage - val groupId = "$server.$roomToken" - - val existingOpenGroup = storage.getOpenGroup(roomToken, server) - val publicKey = existingOpenGroup?.publicKey ?: return - val openGroup = OpenGroup( - server = server, - room = pollInfo.token, - name = pollInfo.details?.name ?: "", - infoUpdates = pollInfo.details?.infoUpdates ?: 0, - publicKey = publicKey, - ) - // - Open Group changes - storage.updateOpenGroup(openGroup) - - // - User Count - storage.setUserCount(roomToken, server, pollInfo.activeUsers) - - // - Moderators - pollInfo.details?.moderators?.let { moderatorList -> - storage.setGroupMemberRoles(moderatorList.map { - GroupMember(groupId, it, GroupMemberRole.MODERATOR) - }) - } - pollInfo.details?.hiddenModerators?.let { moderatorList -> - storage.setGroupMemberRoles(moderatorList.map { - GroupMember(groupId, it, GroupMemberRole.HIDDEN_MODERATOR) - }) - } - // - Admins - pollInfo.details?.admins?.let { moderatorList -> - storage.setGroupMemberRoles(moderatorList.map { - GroupMember(groupId, it, GroupMemberRole.ADMIN) - }) - } - pollInfo.details?.hiddenAdmins?.let { moderatorList -> - storage.setGroupMemberRoles(moderatorList.map { - GroupMember(groupId, it, GroupMemberRole.HIDDEN_ADMIN) - }) - } - } - + private fun handleMessages( server: String, roomToken: String, @@ -211,7 +262,8 @@ class OpenGroupPoller(private val server: String, private val executorService: S null, fromOutbox, if (fromOutbox) it.recipient else it.sender, - serverPublicKey + serverPublicKey, + emptySet() // this shouldn't be necessary as we are polling open groups here ) if (fromOutbox) { val mapping = mappingCache[it.recipient] ?: storage.getOrCreateBlindedIdMapping( @@ -228,7 +280,8 @@ class OpenGroupPoller(private val server: String, private val executorService: S } mappingCache[it.recipient] = mapping } - MessageReceiver.handle(message, proto, null) + val threadId = Message.getThreadId(message, null, MessagingModuleConfiguration.shared.storage, false) + MessageReceiver.handle(message, proto, threadId ?: -1, null) } catch (e: Exception) { Log.e("Loki", "Couldn't handle direct message", e) } @@ -284,16 +337,4 @@ class OpenGroupPoller(private val server: String, private val executorService: S JobQueue.shared.add(deleteJob) } } - - private fun downloadGroupAvatarIfNeeded(room: String) { - val storage = MessagingModuleConfiguration.shared.storage - if (storage.getGroupAvatarDownloadJob(server, room) != null) return - val groupId = GroupUtil.getEncodedOpenGroupID("$server.$room".toByteArray()) - storage.getGroup(groupId)?.let { - if (System.currentTimeMillis() > it.updatedTimestamp + TimeUnit.DAYS.toMillis(7)) { - JobQueue.shared.add(GroupAvatarDownloadJob(room, server)) - } - } - } - } \ No newline at end of file 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 4a39b70c0f..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 @@ -1,5 +1,14 @@ package org.session.libsession.messaging.sending_receiving.pollers +import android.util.SparseArray +import androidx.core.util.valueIterator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.Contacts +import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.UserGroupsConfig +import network.loki.messenger.libsession_util.UserProfile import nl.komponents.kovenant.Deferred import nl.komponents.kovenant.Promise import nl.komponents.kovenant.deferred @@ -10,17 +19,23 @@ import org.session.libsession.messaging.MessagingModuleConfiguration 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.messages.control.SharedConfigurationMessage +import org.session.libsession.messaging.sending_receiving.MessageReceiver +import org.session.libsession.snode.RawResponse import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeModule +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Snode import java.security.SecureRandom import java.util.Timer import java.util.TimerTask +import kotlin.time.Duration.Companion.days private class PromiseCanceledException : Exception("Promise canceled.") -class Poller { +class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Timer) { var userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey() ?: "" private var hasStarted: Boolean = false private val usedSnodes: MutableSet<Snode> = mutableSetOf() @@ -97,23 +112,162 @@ class Poller { } } + private fun processPersonalMessages(snode: Snode, rawMessages: RawResponse) { + val messages = SnodeAPI.parseRawMessagesResponse(rawMessages, snode, userPublicKey) + val parameters = messages.map { (envelope, serverHash) -> + MessageReceiveParameters(envelope.toByteArray(), serverHash = serverHash) + } + parameters.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { chunk -> + val job = BatchMessageReceiveJob(chunk) + JobQueue.shared.add(job) + } + } + + private fun processConfig(snode: Snode, rawMessages: RawResponse, namespace: Int, forConfigObject: ConfigBase?) { + if (forConfigObject == null) return + + val messages = SnodeAPI.parseRawMessagesResponse( + rawMessages, + snode, + userPublicKey, + namespace, + updateLatestHash = true, + updateStoredHashes = true, + ) + + if (messages.isEmpty()) { + // no new messages to process + return + } + + var latestMessageTimestamp: Long? = null + messages.forEach { (envelope, hash) -> + try { + val (message, _) = MessageReceiver.parse(data = envelope.toByteArray(), + // assume no groups in personal poller messages + openGroupServerID = null, currentClosedGroups = emptySet() + ) + // sanity checks + if (message !is SharedConfigurationMessage) { + Log.w("Loki", "shared config message handled in configs wasn't SharedConfigurationMessage but was ${message.javaClass.simpleName}") + return@forEach + } + 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 + // latestMessageTimestamp should always be non-null if the config object needs dump + if (forConfigObject.needsDump() && latestMessageTimestamp != null) { + configFactory.persist(forConfigObject, latestMessageTimestamp ?: SnodeAPI.nowWithOffset) + } + } + private fun poll(snode: Snode, deferred: Deferred<Unit, Exception>): Promise<Unit, Exception> { if (!hasStarted) { return Promise.ofFail(PromiseCanceledException()) } - return SnodeAPI.getRawMessages(snode, userPublicKey).bind { rawResponse -> - isCaughtUp = true - if (deferred.promise.isDone()) { - task { Unit } // The long polling connection has been canceled; don't recurse - } else { - val messages = SnodeAPI.parseRawMessagesResponse(rawResponse, snode, userPublicKey) - val parameters = messages.map { (envelope, serverHash) -> - MessageReceiveParameters(envelope.toByteArray(), serverHash = serverHash) + return task { + runBlocking(Dispatchers.IO) { + val requestSparseArray = SparseArray<SnodeAPI.SnodeBatchRequestInfo>() + // get messages + SnodeAPI.buildAuthenticatedRetrieveBatchRequest(snode, userPublicKey, maxSize = -2)!!.also { personalMessages -> + // namespaces here should always be set + requestSparseArray[personalMessages.namespace!!] = personalMessages } - parameters.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { chunk -> - val job = BatchMessageReceiveJob(chunk) - JobQueue.shared.add(job) + // get the latest convo info volatile + val hashesToExtend = mutableSetOf<String>() + configFactory.getUserConfigs().mapNotNull { config -> + hashesToExtend += config.currentHashes() + SnodeAPI.buildAuthenticatedRetrieveBatchRequest( + snode, userPublicKey, + config.configNamespace(), + maxSize = -8 + ) + }.forEach { request -> + // namespaces here should always be set + requestSparseArray[request.namespace!!] = request } - poll(snode, deferred) + val requests = + requestSparseArray.valueIterator().asSequence().toMutableList() + + if (hashesToExtend.isNotEmpty()) { + SnodeAPI.buildAuthenticatedAlterTtlBatchRequest( + messageHashes = hashesToExtend.toList(), + publicKey = userPublicKey, + newExpiry = SnodeAPI.nowWithOffset + 14.days.inWholeMilliseconds, + extend = true + )?.let { extensionRequest -> + requests += extensionRequest + } + } + + SnodeAPI.getRawBatchResponse(snode, userPublicKey, requests).bind { rawResponses -> + isCaughtUp = true + if (deferred.promise.isDone()) { + return@bind Promise.ofSuccess(Unit) + } else { + 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 + listOfNotNull( + configFactory.user?.configNamespace(), + configFactory.contacts?.configNamespace(), + configFactory.userGroups?.configNamespace(), + configFactory.convoVolatile?.configNamespace() + ).map { + it to requestSparseArray.indexOfKey(it) + }.filter { (_, i) -> i >= 0 }.forEach { (key, requestIndex) -> + responseList.getOrNull(requestIndex)?.let { rawResponse -> + if (rawResponse["code"] as? Int != 200) { + Log.e("Loki", "Batch sub-request had non-200 response code, returned code ${(rawResponse["code"] as? Int) ?: "[unknown]"}") + return@forEach + } + val body = rawResponse["body"] as? RawResponse + if (body == null) { + Log.e("Loki", "Batch sub-request didn't contain a body") + return@forEach + } + if (key == Namespace.DEFAULT) { + return@forEach // continue, skip default namespace + } else { + when (ConfigBase.kindFor(key)) { + UserProfile::class.java -> processConfig(snode, body, key, configFactory.user) + Contacts::class.java -> processConfig(snode, body, key, configFactory.contacts) + ConversationVolatileConfig::class.java -> processConfig(snode, body, key, configFactory.convoVolatile) + UserGroupsConfig::class.java -> processConfig(snode, body, key, configFactory.userGroups) + } + } + } + } + + // the first response will be the personal messages (we want these to be processed after config messages) + val personalResponseIndex = requestSparseArray.indexOfKey(Namespace.DEFAULT) + if (personalResponseIndex >= 0) { + responseList.getOrNull(personalResponseIndex)?.let { rawResponse -> + if (rawResponse["code"] as? Int != 200) { + Log.e("Loki", "Batch sub-request for personal messages had non-200 response code, returned code ${(rawResponse["code"] as? Int) ?: "[unknown]"}") + } else { + val body = rawResponse["body"] as? RawResponse + if (body == null) { + Log.e("Loki", "Batch sub-request for personal messages didn't contain a body") + } else { + processPersonalMessages(snode, body) + } + } + } + } + + poll(snode, deferred) + } + }.fail { + Log.e("Loki", "Failed to get raw batch response", it) + poll(snode, deferred) + } } } } 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 52fe83170a..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,121 +1,111 @@ 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 +import org.session.libsession.messaging.calls.CallMessageType.CALL_FIRST_MISSED +import org.session.libsession.messaging.calls.CallMessageType.CALL_INCOMING +import org.session.libsession.messaging.calls.CallMessageType.CALL_MISSED +import org.session.libsession.messaging.calls.CallMessageType.CALL_OUTGOING import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.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 buildGroupUpdateMessage(context: Context, updateMessageData: UpdateMessageData, sender: String? = null, isOutgoing: Boolean = false): String { - var message = "" - val updateData = updateMessageData.kind ?: return message - if (!isOutgoing && sender == null) return message - val storage = MessagingModuleConfiguration.shared.storage - val senderName: String = if (!isOutgoing) { - storage.getContactWithSessionID(sender!!)?.displayName(Contact.ContactContext.REGULAR) ?: sender - } else { context.getString(R.string.MessageRecord_you) } + private fun getSenderName(senderId: String) = storage.getContactWithSessionID(senderId) + ?.displayName(Contact.ContactContext.REGULAR) + ?: truncateIdForDisplay(senderId) - when (updateData) { + fun buildGroupUpdateMessage(context: Context, updateMessageData: UpdateMessageData, senderId: String? = null, isOutgoing: Boolean = false): String { + val updateData = updateMessageData.kind + if (updateData == null || !isOutgoing && senderId == null) return "" + val senderName: String = if (isOutgoing) context.getString(R.string.MessageRecord_you) + else getSenderName(senderId!!) + + return when (updateData) { is UpdateMessageData.Kind.GroupCreation -> { - message = 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) - } + 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 -> { - message = 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) - } + 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(", ") { - storage.getContactWithSessionID(it)?.displayName(Contact.ContactContext.REGULAR) ?: it - } - message = 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) - } + 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) } is UpdateMessageData.Kind.GroupMemberRemoved -> { - val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey()!! // 1st case: you are part of the removed members - message = 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) - } + 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) } else { // 2nd case: you are not part of the removed members - val members = updateData.updatedMembers.joinToString(", ") { - storage.getContactWithSessionID(it)?.displayName(Contact.ContactContext.REGULAR) ?: it - } - 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) - } + 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) } } is UpdateMessageData.Kind.GroupMemberLeft -> { - message = if (isOutgoing) { - context.getString(R.string.MessageRecord_left_group) - } else { - context.getString(R.string.ConversationItem_group_action_left, senderName) - } + if (isOutgoing) context.getString(R.string.MessageRecord_left_group) + else context.getString(R.string.ConversationItem_group_action_left, senderName) } + else -> return "" } - return message } - fun buildExpirationTimerMessage(context: Context, duration: Long, sender: String? = null, isOutgoing: Boolean = false): String { - if (!isOutgoing && sender == null) return "" - val storage = MessagingModuleConfiguration.shared.storage - val senderName: String? = if (!isOutgoing) { - storage.getContactWithSessionID(sender!!)?.displayName(Contact.ContactContext.REGULAR) ?: sender - } else { context.getString(R.string.MessageRecord_you) } + 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 = 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, sender: String? = null): String { - val storage = MessagingModuleConfiguration.shared.storage - val senderName = storage.getContactWithSessionID(sender!!)?.displayName(Contact.ContactContext.REGULAR) ?: sender!! - 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 { - val storage = MessagingModuleConfiguration.shared.storage - val senderName = storage.getContactWithSessionID(sender)?.displayName(Contact.ContactContext.REGULAR) ?: sender - return when (type) { - CallMessageType.CALL_MISSED -> - context.getString(R.string.MessageRecord_missed_call_from, senderName) - CallMessageType.CALL_INCOMING -> - context.getString(R.string.MessageRecord_s_called_you, senderName) - CallMessageType.CALL_OUTGOING -> - context.getString(R.string.MessageRecord_called_s, senderName) - CallMessageType.CALL_FIRST_MISSED -> - context.getString(R.string.MessageRecord_missed_call_from, senderName) + fun buildCallMessage(context: Context, type: CallMessageType, sender: String): String = + when (type) { + CALL_INCOMING -> R.string.MessageRecord_s_called_you + CALL_OUTGOING -> R.string.MessageRecord_called_s + CALL_MISSED, CALL_FIRST_MISSED -> R.string.MessageRecord_missed_call_from + }.let { + context.getString(it, storage.getContactWithSessionID(sender)?.displayName(Contact.ContactContext.REGULAR) ?: sender) } - } } 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 f93a7b243e..04b0f722c1 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -26,6 +26,7 @@ import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.recover import org.session.libsignal.utilities.toHexString import java.util.Date +import java.util.concurrent.atomic.AtomicReference import kotlin.collections.set private typealias Path = List<Snode> @@ -43,13 +44,27 @@ object OnionRequestAPI { private val snodeFailureCount = mutableMapOf<Snode, Int>() var guardSnodes = setOf<Snode>() + var _paths: AtomicReference<List<Path>?> = AtomicReference(null) var paths: List<Path> // Not a set to ensure we consistently show the same path to the user - get() = database.getOnionRequestPaths() + get() { + val paths = _paths.get() + + if (paths != null) { return paths } + + // Storing this in an atomic variable as it was causing a number of background + // ANRs when this value was accessed via the main thread after tapping on + // a notification) + val result = database.getOnionRequestPaths() + _paths.set(result) + return result + } set(newValue) { if (newValue.isEmpty()) { database.clearOnionRequestPaths() + _paths.set(null) } else { database.setOnionRequestPaths(newValue) + _paths.set(newValue) } } @@ -78,8 +93,8 @@ object OnionRequestAPI { // endregion class HTTPRequestFailedBlindingRequiredException(statusCode: Int, json: Map<*, *>, destination: String): HTTPRequestFailedAtDestinationException(statusCode, json, destination) - open class HTTPRequestFailedAtDestinationException(val statusCode: Int, val json: Map<*, *>, val destination: String) - : Exception("HTTP request failed at destination ($destination) with status code $statusCode.") + open class HTTPRequestFailedAtDestinationException(statusCode: Int, json: Map<*, *>, val destination: String) + : HTTP.HTTPRequestFailedException(statusCode, json, "HTTP request failed at destination ($destination) with status code $statusCode.") class InsufficientSnodesException : Exception("Couldn't find enough snodes to build a path.") private data class OnionBuildingResult( @@ -404,6 +419,8 @@ object OnionRequestAPI { Log.d("Loki","Destination server returned ${exception.statusCode}") } else if (message == "Loki Server error") { Log.d("Loki", "message was $message") + } else if (exception.statusCode == 404) { + // 404 is probably file server missing a file, don't rebuild path or mark a snode as bad here } else { // Only drop snode/path if not receiving above two exception cases handleUnspecificError() } @@ -431,8 +448,8 @@ object OnionRequestAPI { val payloadData = JsonUtil.toJson(payload).toByteArray() return sendOnionRequest(Destination.Snode(snode), payloadData, version).recover { exception -> val error = when (exception) { - is HTTP.HTTPRequestFailedException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) is HTTPRequestFailedAtDestinationException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) + is HTTP.HTTPRequestFailedException -> SnodeAPI.handleSnodeError(exception.statusCode, exception.json, snode, publicKey) else -> null } if (error != null) { throw error } @@ -594,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")) { @@ -669,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 9a17958952..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 @@ -28,12 +27,12 @@ import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.prettifiedDescription import org.session.libsignal.utilities.retryIfNeeded import java.security.SecureRandom -import java.util.Date import java.util.Locale import kotlin.collections.component1 import kotlin.collections.component2 @@ -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 @@ -56,6 +54,11 @@ object SnodeAPI { * user's clock is incorrect. */ internal var clockOffset = 0L + + @JvmStatic + public val nowWithOffset + get() = System.currentTimeMillis() + clockOffset + internal var forkInfo by observable(database.getForkInfo()) { _, oldValue, newValue -> if (newValue > oldValue) { Log.d("Loki", "Setting new fork info new: $newValue, old: $oldValue") @@ -68,12 +71,16 @@ object SnodeAPI { private val minimumSnodePoolCount = 12 private val minimumSwarmSnodeCount = 3 // Use port 4433 if the API level can handle the network security configuration and enforce pinned certificates - private val seedNodePort = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) 443 else 4433 + private val seedNodePort = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) 443 else 4443 private val seedNodePool by lazy { if (useTestnet) { setOf( "http://public.loki.foundation:38157" ) } else { - setOf( "https://storage.seed1.loki.network:$seedNodePort", "https://storage.seed3.loki.network:$seedNodePort", "https://public.loki.foundation:$seedNodePort" ) + setOf( + "https://seed1.getsession.org:$seedNodePort", + "https://seed2.getsession.org:$seedNodePort", + "https://seed3.getsession.org:$seedNodePort", + ) } } private const val snodeFailureThreshold = 3 @@ -93,6 +100,14 @@ object SnodeAPI { object ValidationFailed : Error("ONS name validation failed.") } + // Batch + data class SnodeBatchRequestInfo( + val method: String, + val params: Map<String, Any>, + @Transient + val namespace: Int? + ) // assume signatures, pubkey and namespaces are attached in parameters if required + // Internal API internal fun invoke( method: Snode.Method, @@ -310,26 +325,32 @@ object SnodeAPI { fun getRawMessages(snode: Snode, publicKey: String, requiresAuth: Boolean = true, namespace: Int = 0): RawResponsePromise { // Get last message hash val lastHashValue = database.getLastMessageHashValue(snode, publicKey, namespace) ?: "" - val parameters = mutableMapOf<String,Any>( + val parameters = mutableMapOf<String, Any>( "pubKey" to publicKey, "last_hash" to lastHashValue, ) // Construct signature if (requiresAuth) { val userED25519KeyPair = try { - MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return Promise.ofFail(Error.NoKeyPair) + MessagingModuleConfiguration.shared.getUserED25519KeyPair() + ?: return Promise.ofFail(Error.NoKeyPair) } catch (e: Exception) { Log.e("Loki", "Error getting KeyPair", e) return Promise.ofFail(Error.NoKeyPair) } - val timestamp = Date().time + SnodeAPI.clockOffset + val timestamp = System.currentTimeMillis() + clockOffset val ed25519PublicKey = userED25519KeyPair.publicKey.asHexString val signature = ByteArray(Sign.BYTES) val verificationData = if (namespace != 0) "retrieve$namespace$timestamp".toByteArray() else "retrieve$timestamp".toByteArray() try { - sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), userED25519KeyPair.secretKey.asBytes) + sodium.cryptoSignDetached( + signature, + verificationData, + verificationData.size.toLong(), + userED25519KeyPair.secretKey.asBytes + ) } catch (exception: Exception) { return Promise.ofFail(Error.SigningFailed) } @@ -345,7 +366,252 @@ object SnodeAPI { } // Make the request - return invoke(Snode.Method.GetMessages, snode, parameters, publicKey) + return invoke(Snode.Method.Retrieve, snode, parameters, publicKey) + } + + fun buildAuthenticatedStoreBatchInfo(publicKey: String, namespace: Int, message: SnodeMessage): SnodeBatchRequestInfo? { + val params = mutableMapOf<String, Any>() + // load the message data params into the sub request + // currently loads: + // pubKey + // data + // ttl + // timestamp + params.putAll(message.toJSON()) + params["namespace"] = namespace + + // used for sig generation since it is also the value used in timestamp parameter + val messageTimestamp = message.timestamp + + val userEd25519KeyPair = try { + MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null + } catch (e: Exception) { + return null + } + + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val signature = ByteArray(Sign.BYTES) + val verificationData = "store$namespace$messageTimestamp".toByteArray() + try { + sodium.cryptoSignDetached( + signature, + verificationData, + verificationData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + } + // timestamp already set + params["pubkey_ed25519"] = ed25519PublicKey + params["signature"] = Base64.encodeBytes(signature) + return SnodeBatchRequestInfo( + Snode.Method.SendMessage.rawValue, + params, + namespace + ) + } + + /** + * Message hashes can be shared across multiple namespaces (for a single public key destination) + * @param publicKey the destination's identity public key to delete from (05...) + * @param messageHashes a list of stored message hashes to delete from the server + * @param required indicates that *at least one* message in the list is deleted from the server, otherwise it will return 404 + */ + fun buildAuthenticatedDeleteBatchInfo(publicKey: String, messageHashes: List<String>, required: Boolean = false): SnodeBatchRequestInfo? { + val params = mutableMapOf( + "pubkey" to publicKey, + "required" to required, // could be omitted technically but explicit here + "messages" to messageHashes + ) + val userEd25519KeyPair = try { + MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null + } catch (e: Exception) { + return null + } + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val signature = ByteArray(Sign.BYTES) + val verificationData = "delete${messageHashes.joinToString("")}".toByteArray() + try { + sodium.cryptoSignDetached( + signature, + verificationData, + verificationData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + return null + } + params["pubkey_ed25519"] = ed25519PublicKey + params["signature"] = Base64.encodeBytes(signature) + return SnodeBatchRequestInfo( + Snode.Method.DeleteMessage.rawValue, + params, + null + ) + } + + fun buildAuthenticatedRetrieveBatchRequest(snode: Snode, publicKey: String, namespace: Int = 0, maxSize: Int? = null): SnodeBatchRequestInfo? { + val lastHashValue = database.getLastMessageHashValue(snode, publicKey, namespace) ?: "" + val params = mutableMapOf<String, Any>( + "pubkey" to publicKey, + "last_hash" to lastHashValue, + ) + val userEd25519KeyPair = try { + MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null + } catch (e: Exception) { + return null + } + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val timestamp = System.currentTimeMillis() + clockOffset + val signature = ByteArray(Sign.BYTES) + val verificationData = if (namespace == 0) "retrieve$timestamp".toByteArray() + else "retrieve$namespace$timestamp".toByteArray() + try { + sodium.cryptoSignDetached( + signature, + verificationData, + verificationData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + return null + } + params["timestamp"] = timestamp + params["pubkey_ed25519"] = ed25519PublicKey + params["signature"] = Base64.encodeBytes(signature) + if (namespace != 0) { + params["namespace"] = namespace + } + if (maxSize != null) { + params["max_size"] = maxSize + } + return SnodeBatchRequestInfo( + Snode.Method.Retrieve.rawValue, + params, + namespace + ) + } + + fun buildAuthenticatedAlterTtlBatchRequest( + messageHashes: List<String>, + newExpiry: Long, + publicKey: String, + shorten: Boolean = false, + extend: Boolean = false): SnodeBatchRequestInfo? { + val params = buildAlterTtlParams(messageHashes, newExpiry, publicKey, extend, shorten) ?: return null + return SnodeBatchRequestInfo( + Snode.Method.Expire.rawValue, + params, + null + ) + } + + fun getRawBatchResponse(snode: Snode, publicKey: String, requests: List<SnodeBatchRequestInfo>, sequence: Boolean = false): RawResponsePromise { + val parameters = mutableMapOf<String, Any>( + "requests" to requests + ) + return invoke(if (sequence) Snode.Method.Sequence else Snode.Method.Batch, snode, parameters, publicKey).success { rawResponses -> + val responseList = (rawResponses["results"] as List<RawResponse>) + responseList.forEachIndexed { index, response -> + if (response["code"] as? Int != 200) { + Log.w("Loki", "response code was not 200") + handleSnodeError( + response["code"] as? Int ?: 0, + response, + snode, + publicKey + ) + } + } + } + } + + 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 signData = "${Snode.Method.GetExpiries.rawValue}$timestamp${hashes.joinToString(separator = "")}".toByteArray() + + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val signature = ByteArray(Sign.BYTES) + try { + sodium.cryptoSignDetached( + signature, + signData, + signData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + return@retryIfNeeded Promise.ofFail(e) + } + 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) + } + } + } + + fun alterTtl(messageHashes: List<String>, newExpiry: Long, publicKey: String, extend: Boolean = false, shorten: Boolean = false): RawResponsePromise { + return retryIfNeeded(maxRetryCount) { + val params = buildAlterTtlParams(messageHashes, newExpiry, publicKey, extend, shorten) + ?: return@retryIfNeeded Promise.ofFail( + Exception("Couldn't build signed params for alterTtl request for newExpiry=$newExpiry, extend=$extend, shorten=$shorten") + ) + getSingleTargetSnode(publicKey).bind { snode -> + invoke(Snode.Method.Expire, snode, params, publicKey) + } + } + } + + private fun buildAlterTtlParams( // TODO: in future this will probably need to use the closed group subkeys / admin keys for group swarms + messageHashes: List<String>, + newExpiry: Long, + publicKey: String, + extend: Boolean = false, + shorten: Boolean = false): Map<String, Any>? { + val userEd25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null + val params = mutableMapOf( + "expiry" to newExpiry, + "messages" to messageHashes, + ) + if (extend) { + params["extend"] = true + } else if (shorten) { + params["shorten"] = true + } + val shortenOrExtend = if (extend) "extend" else if (shorten) "shorten" else "" + + val signData = "${Snode.Method.Expire.rawValue}$shortenOrExtend$newExpiry${messageHashes.joinToString(separator = "")}".toByteArray() + + val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString + val signature = ByteArray(Sign.BYTES) + try { + sodium.cryptoSignDetached( + signature, + signData, + signData.size.toLong(), + userEd25519KeyPair.secretKey.asBytes + ) + } catch (e: Exception) { + Log.e("Loki", "Signing data failed with user secret key", e) + return null + } + params["pubkey"] = publicKey + params["pubkey_ed25519"] = ed25519PublicKey + params["signature"] = Base64.encodeBytes(signature) + + return params } fun getMessages(publicKey: String): MessageListPromise { @@ -371,7 +637,7 @@ object SnodeAPI { val parameters = message.toJSON().toMutableMap<String,Any>() // Construct signature if (requiresAuth) { - val sigTimestamp = System.currentTimeMillis() + SnodeAPI.clockOffset + val sigTimestamp = nowWithOffset val ed25519PublicKey = userED25519KeyPair.publicKey.asHexString val signature = ByteArray(Sign.BYTES) // assume namespace here is non-zero, as zero namespace doesn't require auth @@ -474,13 +740,14 @@ object SnodeAPI { retryIfNeeded(maxRetryCount) { getNetworkTime(snode).bind { (_, timestamp) -> val signature = ByteArray(Sign.BYTES) - val verificationData = (Snode.Method.DeleteAll.rawValue + timestamp.toString()).toByteArray() + val verificationData = (Snode.Method.DeleteAll.rawValue + Namespace.ALL + timestamp.toString()).toByteArray() sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), userED25519KeyPair.secretKey.asBytes) val deleteMessageParams = mapOf( "pubkey" to userPublicKey, "pubkey_ed25519" to userED25519KeyPair.publicKey.asHexString, "timestamp" to timestamp, - "signature" to Base64.encodeBytes(signature) + "signature" to Base64.encodeBytes(signature), + "namespace" to Namespace.ALL, ) invoke(Snode.Method.DeleteAll, snode, deleteMessageParams, userPublicKey).map { rawResponse -> parseDeletions(userPublicKey, timestamp, rawResponse) @@ -493,11 +760,69 @@ object SnodeAPI { } } - fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0): List<Pair<SignalServiceProtos.Envelope, String?>> { + 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) { - updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace) - val newRawMessages = removeDuplicates(publicKey, messages, namespace) + if (updateLatestHash) { + updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace) + } + val newRawMessages = removeDuplicates(publicKey, messages, namespace, updateStoredHashes) return parseEnvelopes(newRawMessages) } else { listOf() @@ -514,7 +839,7 @@ object SnodeAPI { } } - private fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int): List<*> { + private fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List<*> { val originalMessageHashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf() val receivedMessageHashValues = originalMessageHashValues.toMutableSet() val result = rawMessages.filter { rawMessage -> @@ -529,7 +854,7 @@ object SnodeAPI { false } } - if (originalMessageHashValues != receivedMessageHashValues) { + if (originalMessageHashValues != receivedMessageHashValues && updateStoredHashes) { database.setReceivedMessageHashValues(publicKey, receivedMessageHashValues, namespace) } return result @@ -566,11 +891,11 @@ object SnodeAPI { Log.e("Loki", "Failed to delete all messages from: $hexSnodePublicKey due to error: $reason ($statusCode).") false } else { - val hashes = json["deleted"] as List<String> // Hashes of deleted messages + val hashes = (json["deleted"] as Map<String,List<String>>).flatMap { (_, hashes) -> hashes }.sorted() // Hashes of deleted messages val signature = json["signature"] as String val snodePublicKey = Key.fromHexString(hexSnodePublicKey) // The signature looks like ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) - val message = (userPublicKey + timestamp.toString() + hashes.fold("") { a, v -> a + v }).toByteArray() + val message = (userPublicKey + timestamp.toString() + hashes.joinToString(separator = "")).toByteArray() sodium.cryptoSignVerifyDetached(Base64.decode(signature), message, message.size, snodePublicKey.asBytes) } } @@ -626,6 +951,10 @@ object SnodeAPI { Log.d("Loki", "Got a 421 without an associated public key.") } } + 404 -> { + Log.d("Loki", "404, probably no file found") + return Error.Generic + } else -> { handleBadSnode() Log.d("Loki", "Unhandled response code: ${statusCode}.") diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeMessage.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeMessage.kt index 31ced16209..a141de30ca 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeMessage.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeMessage.kt @@ -20,6 +20,7 @@ data class SnodeMessage( */ val timestamp: Long ) { + internal constructor(): this("", "", -1, -1) internal fun toJSON(): Map<String, String> { return mapOf( diff --git a/libsession/src/main/java/org/session/libsession/utilities/AESGCM.kt b/libsession/src/main/java/org/session/libsession/utilities/AESGCM.kt index 225900b096..4a6a588dc2 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/AESGCM.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/AESGCM.kt @@ -1,6 +1,7 @@ package org.session.libsession.utilities import androidx.annotation.WorkerThread +import org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK import org.session.libsignal.utilities.ByteUtil import org.session.libsignal.utilities.Util import org.session.libsignal.utilities.Hex @@ -27,9 +28,11 @@ internal object AESGCM { internal fun decrypt(ivAndCiphertext: ByteArray, symmetricKey: ByteArray): ByteArray { val iv = ivAndCiphertext.sliceArray(0 until ivSize) val ciphertext = ivAndCiphertext.sliceArray(ivSize until ivAndCiphertext.count()) - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv)) - return cipher.doFinal(ciphertext) + synchronized(CIPHER_LOCK) { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv)) + return cipher.doFinal(ciphertext) + } } /** @@ -47,9 +50,11 @@ internal object AESGCM { */ internal fun encrypt(plaintext: ByteArray, symmetricKey: ByteArray): ByteArray { val iv = Util.getSecretBytes(ivSize) - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv)) - return ByteUtil.combine(iv, cipher.doFinal(plaintext)) + synchronized(CIPHER_LOCK) { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(symmetricKey, "AES"), GCMParameterSpec(gcmTagSize, iv)) + return ByteUtil.combine(iv, cipher.doFinal(plaintext)) + } } /** 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 7b774602e1..920b466a8b 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Address.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/Address.kt @@ -5,11 +5,10 @@ import android.os.Parcel import android.os.Parcelable import android.util.Pair import androidx.annotation.VisibleForTesting -import org.session.libsession.utilities.DelimiterUtil -import org.session.libsession.utilities.GroupUtil -import org.session.libsignal.utilities.guava.Optional +import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Util -import java.util.* +import org.session.libsignal.utilities.guava.Optional +import java.util.LinkedList import java.util.concurrent.atomic.AtomicReference import java.util.regex.Matcher import java.util.regex.Pattern @@ -23,15 +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 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") } @@ -166,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 @@ -175,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 new file mode 100644 index 0000000000..8add1da849 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt @@ -0,0 +1,23 @@ +package org.session.libsession.utilities + +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.Contacts +import network.loki.messenger.libsession_util.ConversationVolatileConfig +import network.loki.messenger.libsession_util.UserGroupsConfig +import network.loki.messenger.libsession_util.UserProfile + +interface ConfigFactoryProtocol { + val user: UserProfile? + val contacts: Contacts? + val convoVolatile: ConversationVolatileConfig? + val userGroups: UserGroupsConfig? + fun getUserConfigs(): List<ConfigBase> + fun persist(forConfigObject: ConfigBase, timestamp: Long) + + fun conversationInConfig(publicKey: String?, groupPublicKey: String?, openGroupId: String?, visibleOnly: Boolean): Boolean + fun canPerformChange(variant: String, publicKey: String, changeTimestampMs: Long): Boolean +} + +interface ConfigFactoryUpdateListener { + 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/DownloadUtilities.kt b/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt index 0a61d1ede0..27b6b244ba 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/DownloadUtilities.kt @@ -2,8 +2,11 @@ package org.session.libsession.utilities import okhttp3.HttpUrl import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Log -import java.io.* +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStream object DownloadUtilities { @@ -13,7 +16,7 @@ object DownloadUtilities { @JvmStatic fun downloadFile(destination: File, url: String) { val outputStream = FileOutputStream(destination) // Throws - var remainingAttempts = 4 + var remainingAttempts = 2 var exception: Exception? = null while (remainingAttempts > 0) { remainingAttempts -= 1 @@ -40,7 +43,11 @@ object DownloadUtilities { outputStream.write(it) } } catch (e: Exception) { - Log.e("Loki", "Couldn't download attachment.", e) + when (e) { + // No need for the stack trace for HTTP errors + is HTTP.HTTPRequestFailedException -> Log.e("Loki", "Couldn't download attachment due to error: ${e.message}") + else -> Log.e("Loki", "Couldn't download attachment", e) + } throw e } } 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 3458e06eb6..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 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 -import kotlin.jvm.Throws 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 @@ -97,4 +105,29 @@ object GroupUtil { fun doubleDecodeGroupID(groupID: String): ByteArray { return getDecodedGroupIDAsData(getDecodedGroupID(groupID)) } + + @JvmStatic + @Throws(IOException::class) + 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.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 (member !in memberMap) { + memberMap[member] = false + } + } + return memberMap + } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/IdUtil.kt b/libsession/src/main/java/org/session/libsession/utilities/IdUtil.kt new file mode 100644 index 0000000000..ccaa31c2f9 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/IdUtil.kt @@ -0,0 +1,4 @@ +package org.session.libsession.utilities + +fun truncateIdForDisplay(id: String): String = + id.takeIf { it.length > 8 }?.apply{ "${take(4)}…${takeLast(4)}" } ?: id diff --git a/libsession/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java b/libsession/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java index 9e3842fc67..4550965ae7 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java +++ b/libsession/src/main/java/org/session/libsession/utilities/ProfileKeyUtil.java @@ -1,23 +1,24 @@ package org.session.libsession.utilities; import android.content.Context; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.session.libsignal.utilities.Base64; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsession.utilities.Util; import java.io.IOException; public class ProfileKeyUtil { + public static final int PROFILE_KEY_BYTES = 32; + public static synchronized @NonNull byte[] getProfileKey(@NonNull Context context) { try { String encodedProfileKey = TextSecurePreferences.getProfileKey(context); if (encodedProfileKey == null) { - encodedProfileKey = Util.getSecret(32); + encodedProfileKey = Util.getSecret(PROFILE_KEY_BYTES); TextSecurePreferences.setProfileKey(context, encodedProfileKey); } @@ -36,7 +37,7 @@ public class ProfileKeyUtil { } public static synchronized @NonNull String generateEncodedProfileKey(@NonNull Context context) { - return Util.getSecret(32); + return Util.getSecret(PROFILE_KEY_BYTES); } public static synchronized void setEncodedProfileKey(@NonNull Context context, @Nullable String key) { 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 0374b9c001..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.utilities.Address +import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.utilities.recipients.Recipient class SSKEnvironment( @@ -26,20 +32,48 @@ class SSKEnvironment( interface ProfileManagerProtocol { companion object { - const val NAME_PADDED_LENGTH = 26 + const val NAME_PADDED_LENGTH = 64 } fun setNickname(context: Context, recipient: Recipient, nickname: String?) - fun setName(context: Context, recipient: Recipient, name: String) - fun setProfilePictureURL(context: Context, recipient: Recipient, profilePictureURL: String) - fun setProfileKey(context: Context, recipient: Recipient, profileKey: ByteArray) + fun setName(context: Context, recipient: Recipient, name: String?) + fun setProfilePicture(context: Context, recipient: Recipient, profilePictureURL: String?, profileKey: ByteArray?) fun setUnidentifiedAccessMode(context: Context, recipient: Recipient, unidentifiedAccessMode: Recipient.UnidentifiedAccessMode) + fun contactUpdatedInternal(contact: Contact): String? } 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 { @@ -54,4 +88,4 @@ class SSKEnvironment( shared = SSKEnvironment(typingIndicators, readReceiptManager, profileManager, notificationManager, messageExpirationManager) } } -} \ No newline at end of file +} 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 1f05e4f135..af16d93f5a 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -12,7 +12,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import org.session.libsession.BuildConfig import org.session.libsession.R import org.session.libsession.utilities.TextSecurePreferences.Companion.AUTOPLAY_AUDIO_MESSAGES import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED @@ -38,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 @@ -103,6 +102,8 @@ interface TextSecurePreferences { fun setUpdateApkDigest(value: String?) fun getUpdateApkDigest(): String? fun getLocalNumber(): String? + fun getHasLegacyConfig(): Boolean + fun setHasLegacyConfig(newValue: Boolean) fun setLocalNumber(localNumber: String) fun removeLocalNumber() fun isEnterSendsEnabled(): Boolean @@ -178,6 +179,7 @@ interface TextSecurePreferences { fun setThemeStyle(themeStyle: String) fun setFollowSystemSettings(followSystemSettings: Boolean) fun autoplayAudioMessages(): Boolean + fun hasForcedNewConfig(): Boolean fun hasPreference(key: String): Boolean fun clearAll() @@ -187,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" @@ -249,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" @@ -264,6 +269,10 @@ interface TextSecurePreferences { const val AUTOPLAY_AUDIO_MESSAGES = "pref_autoplay_audio" const val FINGERPRINT_KEY_GENERATED = "fingerprint_key_generated" const val SELECTED_ACCENT_COLOR = "selected_accent_color" + + const val HAS_RECEIVED_LEGACY_CONFIG = "has_received_legacy_config" + const val HAS_FORCED_NEW_CONFIG = "has_forced_new_config" + const val GREEN_ACCENT = "accent_green" const val BLUE_ACCENT = "accent_blue" const val PURPLE_ACCENT = "accent_purple" @@ -281,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) @@ -303,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 @@ -625,6 +636,17 @@ interface TextSecurePreferences { return getStringPreference(context, LOCAL_NUMBER_PREF, null) } + @JvmStatic + fun getHasLegacyConfig(context: Context): Boolean { + return getBooleanPreference(context, HAS_RECEIVED_LEGACY_CONFIG, false) + } + + @JvmStatic + fun setHasLegacyConfig(context: Context, newValue: Boolean) { + setBooleanPreference(context, HAS_RECEIVED_LEGACY_CONFIG, newValue) + _events.tryEmit(HAS_RECEIVED_LEGACY_CONFIG) + } + fun setLocalNumber(context: Context, localNumber: String) { setStringPreference(context, LOCAL_NUMBER_PREF, localNumber.toLowerCase()) } @@ -649,7 +671,7 @@ interface TextSecurePreferences { @JvmStatic fun isScreenSecurityEnabled(context: Context): Boolean { - return getBooleanPreference(context, SCREEN_SECURITY_PREF, !BuildConfig.DEBUG) + return getBooleanPreference(context, SCREEN_SECURITY_PREF, context.resources.getBoolean(R.bool.screen_security_default)) } fun getLastVersionCode(context: Context): Int { @@ -795,6 +817,11 @@ interface TextSecurePreferences { setIntegerPreference(context, NOTIFICATION_MESSAGES_CHANNEL_VERSION, version) } + @JvmStatic + fun hasForcedNewConfig(context: Context): Boolean { + return getBooleanPreference(context, HAS_FORCED_NEW_CONFIG, false) + } + @JvmStatic fun getBooleanPreference(context: Context, key: String?, defaultValue: Boolean): Boolean { return getDefaultSharedPreferences(context).getBoolean(key, defaultValue) @@ -815,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) } @@ -986,7 +1013,6 @@ interface TextSecurePreferences { fun clearAll(context: Context) { getDefaultSharedPreferences(context).edit().clear().commit() } - } } @@ -1011,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 { @@ -1279,6 +1305,15 @@ class AppTextSecurePreferences @Inject constructor( return getStringPreference(TextSecurePreferences.LOCAL_NUMBER_PREF, null) } + override fun getHasLegacyConfig(): Boolean { + return getBooleanPreference(TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG, false) + } + + override fun setHasLegacyConfig(newValue: Boolean) { + setBooleanPreference(TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG, newValue) + TextSecurePreferences._events.tryEmit(TextSecurePreferences.HAS_RECEIVED_LEGACY_CONFIG) + } + override fun setLocalNumber(localNumber: String) { setStringPreference(TextSecurePreferences.LOCAL_NUMBER_PREF, localNumber.toLowerCase()) } @@ -1422,6 +1457,9 @@ class AppTextSecurePreferences @Inject constructor( setIntegerPreference(TextSecurePreferences.NOTIFICATION_MESSAGES_CHANNEL_VERSION, version) } + override fun hasForcedNewConfig(): Boolean = + getBooleanPreference(TextSecurePreferences.HAS_FORCED_NEW_CONFIG, false) + override fun getBooleanPreference(key: String?, defaultValue: Boolean): Boolean { return getDefaultSharedPreferences(context).getBoolean(key, defaultValue) } 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 5781ea3e2c..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 @@ -13,4 +15,8 @@ fun Context.getColorFromAttr( ): Int { theme.resolveAttribute(attrColor, typedValue, resolveRefs) return typedValue.data -} \ No newline at end of file +} + +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 a7fa75dd2b..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; @@ -99,6 +100,8 @@ public class Recipient implements RecipientModifiedListener { private boolean profileSharing; private String notificationChannel; private boolean forceSmsSelection; + private String wrapperHash; + private boolean blocksCommunityMessageRequests; private @NonNull UnidentifiedAccessMode unidentifiedAccessMode = UnidentifiedAccessMode.ENABLED; @@ -129,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; @@ -161,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); @@ -191,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); @@ -227,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); @@ -251,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; @@ -279,6 +287,8 @@ public class Recipient implements RecipientModifiedListener { this.profileSharing = details.profileSharing; this.unidentifiedAccessMode = details.unidentifiedAccessMode; this.forceSmsSelection = details.forceSmsSelection; + this.wrapperHash = details.wrapperHash; + this.blocksCommunityMessageRequests = details.blocksCommunityMessageRequests; this.participants.addAll(details.participants); this.resolving = false; @@ -319,13 +329,13 @@ 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); } else { Contact contact = storage.getContactWithSessionID(sessionID); - if (contact == null) { return sessionID; } + if (contact == null) { return null; } return contact.displayName(Contact.ContactContext.REGULAR); } } @@ -343,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; @@ -435,13 +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.isCommunityOutbox(); } public boolean isOpenGroupInboxRecipient() { - return address.isOpenGroupInbox(); + return address.isCommunityInbox(); } public boolean isClosedGroupRecipient() { @@ -483,7 +510,13 @@ public class Recipient implements RecipientModifiedListener { public synchronized String toShortString() { String name = getName(); - return (name != null ? name : address.serialize()); + if (name != null) return name; + String sessionId = address.serialize(); + if (sessionId.length() < 4) return sessionId; // so substrings don't throw out of bounds exceptions + int takeAmount = 4; + String start = sessionId.substring(0, takeAmount); + String end = sessionId.substring(sessionId.length()-takeAmount); + return start+"..."+end; } public synchronized @NonNull Drawable getFallbackContactPhotoDrawable(Context context, boolean inverted) { @@ -653,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; @@ -717,6 +762,14 @@ public class Recipient implements RecipientModifiedListener { return unidentifiedAccessMode; } + public String getWrapperHash() { + return wrapperHash; + } + + public void setWrapperHash(String wrapperHash) { + this.wrapperHash = wrapperHash; + } + public void setUnidentifiedAccessMode(@NonNull UnidentifiedAccessMode unidentifiedAccessMode) { synchronized (this) { this.unidentifiedAccessMode = unidentifiedAccessMode; @@ -734,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); + 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); + 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; } @@ -787,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); @@ -829,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; @@ -848,53 +955,62 @@ public class Recipient implements RecipientModifiedListener { private final String notificationChannel; 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) + @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.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() { @@ -921,6 +1037,10 @@ public class Recipient implements RecipientModifiedListener { return notifyType; } + public @NonNull DisappearingState getDisappearingState() { + return disappearingState; + } + public @NonNull VibrateState getMessageVibrateState() { return messageVibrateState; } @@ -992,6 +1112,15 @@ public class Recipient implements RecipientModifiedListener { public boolean isForceSmsSelection() { return forceSmsSelection; } + + public String getWrapperHash() { + 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 03c225e207..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; @@ -177,6 +179,8 @@ class RecipientProvider { @Nullable final String notificationChannel; @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, @@ -191,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(); @@ -209,6 +214,8 @@ class RecipientProvider { this.notificationChannel = settings != null ? settings.getNotificationChannel() : null; 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 new file mode 100644 index 0000000000..0298ae07f0 --- /dev/null +++ b/libsession/src/main/res/values-fr-rFR/strings.xml @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- MessageRecord --> + <string name="MessageRecord_left_group">Vous avez quitté le groupe.</string> + <string name="MessageRecord_you_created_a_new_group">Vous avez créé un nouveau groupe.</string> + <string name="MessageRecord_s_added_you_to_the_group">%1$s vous a ajouté·e dans le groupe.</string> + <string name="MessageRecord_you_renamed_the_group_to_s">Vous avez renommé le groupe en %1$s</string> + <string name="MessageRecord_s_renamed_the_group_to_s">%1$s a renommé le groupe en : %2$s</string> + <string name="MessageRecord_you_added_s_to_the_group">Vous avez ajouté %1$s au groupe.</string> + <string name="MessageRecord_s_added_s_to_the_group">%1$s a ajouté %2$s au groupe.</string> + <string name="MessageRecord_you_removed_s_from_the_group">Vous avez retiré %1$s du groupe.</string> + <string name="MessageRecord_s_removed_s_from_the_group">%1$s a supprimé %2$s du groupe.</string> + <string name="MessageRecord_you_were_removed_from_the_group">Vous avez été retiré·e du groupe.</string> + <string name="MessageRecord_you">Vous</string> + <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_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 --> + <string name="expiration_off">Désactivé</string> + <plurals name="expiration_seconds"> + <item quantity="one">%d seconde</item> + <item quantity="other">%d secondes</item> + </plurals> + <string name="expiration_seconds_abbreviated">%d s</string> + <plurals name="expiration_minutes"> + <item quantity="one">%d minute</item> + <item quantity="other">%d minutes</item> + </plurals> + <string name="expiration_minutes_abbreviated">%d min</string> + <plurals name="expiration_hours"> + <item quantity="one">%d heure</item> + <item quantity="other">%d heures</item> + </plurals> + <string name="expiration_hours_abbreviated">%d h</string> + <plurals name="expiration_days"> + <item quantity="one">%d jour</item> + <item quantity="other">%d jours</item> + </plurals> + <string name="expiration_days_abbreviated">%d j</string> + <plurals name="expiration_weeks"> + <item quantity="one">%d semaine</item> + <item quantity="other">%d semaines</item> + </plurals> + <string name="expiration_weeks_abbreviated">%d sem</string> + <string name="ConversationItem_group_action_left">%1$s a quitté le groupe.</string> + <!-- RecipientProvider --> + <string name="RecipientProvider_unnamed_group">Groupe sans nom</string> +</resources> diff --git a/libsession/src/main/res/values-fr/strings.xml b/libsession/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000000..0298ae07f0 --- /dev/null +++ b/libsession/src/main/res/values-fr/strings.xml @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- MessageRecord --> + <string name="MessageRecord_left_group">Vous avez quitté le groupe.</string> + <string name="MessageRecord_you_created_a_new_group">Vous avez créé un nouveau groupe.</string> + <string name="MessageRecord_s_added_you_to_the_group">%1$s vous a ajouté·e dans le groupe.</string> + <string name="MessageRecord_you_renamed_the_group_to_s">Vous avez renommé le groupe en %1$s</string> + <string name="MessageRecord_s_renamed_the_group_to_s">%1$s a renommé le groupe en : %2$s</string> + <string name="MessageRecord_you_added_s_to_the_group">Vous avez ajouté %1$s au groupe.</string> + <string name="MessageRecord_s_added_s_to_the_group">%1$s a ajouté %2$s au groupe.</string> + <string name="MessageRecord_you_removed_s_from_the_group">Vous avez retiré %1$s du groupe.</string> + <string name="MessageRecord_s_removed_s_from_the_group">%1$s a supprimé %2$s du groupe.</string> + <string name="MessageRecord_you_were_removed_from_the_group">Vous avez été retiré·e du groupe.</string> + <string name="MessageRecord_you">Vous</string> + <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_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 --> + <string name="expiration_off">Désactivé</string> + <plurals name="expiration_seconds"> + <item quantity="one">%d seconde</item> + <item quantity="other">%d secondes</item> + </plurals> + <string name="expiration_seconds_abbreviated">%d s</string> + <plurals name="expiration_minutes"> + <item quantity="one">%d minute</item> + <item quantity="other">%d minutes</item> + </plurals> + <string name="expiration_minutes_abbreviated">%d min</string> + <plurals name="expiration_hours"> + <item quantity="one">%d heure</item> + <item quantity="other">%d heures</item> + </plurals> + <string name="expiration_hours_abbreviated">%d h</string> + <plurals name="expiration_days"> + <item quantity="one">%d jour</item> + <item quantity="other">%d jours</item> + </plurals> + <string name="expiration_days_abbreviated">%d j</string> + <plurals name="expiration_weeks"> + <item quantity="one">%d semaine</item> + <item quantity="other">%d semaines</item> + </plurals> + <string name="expiration_weeks_abbreviated">%d sem</string> + <string name="ConversationItem_group_action_left">%1$s a quitté le groupe.</string> + <!-- RecipientProvider --> + <string name="RecipientProvider_unnamed_group">Groupe sans nom</string> +</resources> diff --git a/libsession/src/main/res/values/attrs.xml b/libsession/src/main/res/values/attrs.xml index 59e987c54f..474fe565e0 100644 --- a/libsession/src/main/res/values/attrs.xml +++ b/libsession/src/main/res/values/attrs.xml @@ -220,18 +220,6 @@ <attr name="emoji_maxLength" format="integer" /> </declare-styleable> - <declare-styleable name="ColorPickerPreference"> - <attr name="currentColor" format="reference" /> - <attr name="colors" format="reference" /> - <attr name="sortColors" format="boolean|reference" /> - <attr name="colorDescriptions" format="reference" /> - <attr name="columns" format="integer|reference" /> - <attr name="colorSize" format="enum|reference"> - <enum name="large" value="1" /> - <enum name="small" value="2" /> - </attr> - </declare-styleable> - <declare-styleable name="VerificationCodeView"> <attr name="vcv_spacing" format="dimension"/> <attr name="vcv_inputWidth" format="dimension"/> @@ -264,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/main/res/values/values.xml b/libsession/src/main/res/values/values.xml index 369e27339a..f493249a83 100644 --- a/libsession/src/main/res/values/values.xml +++ b/libsession/src/main/res/values/values.xml @@ -2,4 +2,5 @@ <resources> <bool name="enable_alarm_manager">true</bool> <bool name="enable_job_service">false</bool> + <bool name="screen_security_default">true</bool> </resources> 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/libsession/src/test/java/org/session/libsession/utilities/OpenGroupUrlParserTest.kt b/libsession/src/test/java/org/session/libsession/utilities/CommunityUrlParserTest.kt similarity index 98% rename from libsession/src/test/java/org/session/libsession/utilities/OpenGroupUrlParserTest.kt rename to libsession/src/test/java/org/session/libsession/utilities/CommunityUrlParserTest.kt index 38a244699d..64d1c21fb4 100644 --- a/libsession/src/test/java/org/session/libsession/utilities/OpenGroupUrlParserTest.kt +++ b/libsession/src/test/java/org/session/libsession/utilities/CommunityUrlParserTest.kt @@ -1,9 +1,9 @@ package org.session.libsession.utilities +import org.junit.Assert.assertEquals import org.junit.Test -import org.junit.Assert.* -class OpenGroupUrlParserTest { +class CommunityUrlParserTest { @Test fun parseUrlTest() { diff --git a/libsignal/build.gradle b/libsignal/build.gradle index 681fdfa12d..5ac0d0b4fc 100644 --- a/libsignal/build.gradle +++ b/libsignal/build.gradle @@ -15,16 +15,16 @@ android { } dependencies { - implementation "androidx.annotation:annotation:1.2.0" + implementation "androidx.annotation:annotation:1.5.0" implementation "com.google.protobuf:protobuf-java:$protobufVersion" implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion" implementation "com.github.oxen-io.session-android-curve-25519:curve25519-java:$curve25519Version" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" - testImplementation "junit:junit:3.8.2" - testImplementation "org.assertj:assertj-core:1.7.1" + testImplementation "junit:junit:$junitVersion" + 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 e1c1c856d9..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; @@ -51,6 +57,10 @@ message Content { optional DataExtractionNotification dataExtractionNotification = 8; 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 { @@ -162,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 { @@ -233,7 +244,28 @@ message ConfigurationMessage { message MessageRequestResponse { // @required - required bool isApproved = 1; // Whether the request was approved + required bool isApproved = 1; + optional bytes profileKey = 2; + optional DataMessage.LokiProfile profile = 3; +} + +message SharedConfigMessage { + enum Kind { + USER_PROFILE = 1; + CONTACTS = 2; + CONVO_INFO_VOLATILE = 3; + GROUPS = 4; + CLOSED_GROUP_INFO = 5; + CLOSED_GROUP_MEMBERS = 6; + ENCRYPTION_KEYS = 7; + } + + // @required + required Kind kind = 1; + // @required + required int64 seqno = 2; + // @required + required bytes data = 3; } message ReceiptMessage { diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/CipherUtil.java b/libsignal/src/main/java/org/session/libsignal/crypto/CipherUtil.java new file mode 100644 index 0000000000..a6a3808bb4 --- /dev/null +++ b/libsignal/src/main/java/org/session/libsignal/crypto/CipherUtil.java @@ -0,0 +1,8 @@ +package org.session.libsignal.crypto; + +public class CipherUtil { + // Cipher operations are not thread-safe so we synchronize over them through doFinal to + // prevent crashes with quickly repeated encrypt/decrypt operations + // https://github.com/mozilla-mobile/android-components/issues/5342 + public static final Object CIPHER_LOCK = new Object(); +} diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/DiffieHellman.kt b/libsignal/src/main/java/org/session/libsignal/crypto/DiffieHellman.kt deleted file mode 100644 index 2b613247bf..0000000000 --- a/libsignal/src/main/java/org/session/libsignal/crypto/DiffieHellman.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.session.libsignal.crypto - -import org.whispersystems.curve25519.Curve25519 -import org.session.libsignal.utilities.Util -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -object DiffieHellman { - private val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - private val curve = Curve25519.getInstance(Curve25519.BEST) - private val ivSize = 16 - - @JvmStatic @Throws - fun encrypt(plaintext: ByteArray, symmetricKey: ByteArray): ByteArray { - val iv = Util.getSecretBytes(ivSize) - val ivSpec = IvParameterSpec(iv) - val secretKeySpec = SecretKeySpec(symmetricKey, "AES") - cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivSpec) - val ciphertext = cipher.doFinal(plaintext) - return iv + ciphertext - } - - @JvmStatic @Throws - fun encrypt(plaintext: ByteArray, publicKey: ByteArray, privateKey: ByteArray): ByteArray { - val symmetricKey = curve.calculateAgreement(publicKey, privateKey) - return encrypt(plaintext, symmetricKey) - } - - @JvmStatic @Throws - fun decrypt(ivAndCiphertext: ByteArray, symmetricKey: ByteArray): ByteArray { - val iv = ivAndCiphertext.sliceArray(0 until ivSize) - val ciphertext = ivAndCiphertext.sliceArray(ivSize until ivAndCiphertext.size) - val ivSpec = IvParameterSpec(iv) - val secretKeySpec = SecretKeySpec(symmetricKey, "AES") - cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivSpec) - return cipher.doFinal(ciphertext) - } - - @JvmStatic @Throws - fun decrypt(ivAndCiphertext: ByteArray, publicKey: ByteArray, privateKey: ByteArray): ByteArray { - val symmetricKey = curve.calculateAgreement(publicKey, privateKey) - return decrypt(ivAndCiphertext, symmetricKey) - } -} diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/kdf/HKDF.java b/libsignal/src/main/java/org/session/libsignal/crypto/kdf/HKDF.java index 3295f06e52..73c87c075d 100644 --- a/libsignal/src/main/java/org/session/libsignal/crypto/kdf/HKDF.java +++ b/libsignal/src/main/java/org/session/libsignal/crypto/kdf/HKDF.java @@ -39,9 +39,7 @@ public abstract class HKDF { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(salt, "HmacSHA256")); return mac.doFinal(inputKeyMaterial); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } catch (InvalidKeyException e) { + } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new AssertionError(e); } } @@ -73,9 +71,7 @@ public abstract class HKDF { } return results.toByteArray(); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } catch (InvalidKeyException e) { + } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new AssertionError(e); } } 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 a1866bf21e..37c00a037d 100644 --- a/libsignal/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt +++ b/libsignal/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt @@ -16,8 +16,10 @@ interface LokiAPIDatabaseProtocol { fun setSwarm(publicKey: String, newValue: Set<Snode>) fun getLastMessageHashValue(snode: Snode, publicKey: String, namespace: Int): String? fun setLastMessageHashValue(snode: Snode, publicKey: String, newValue: String, namespace: Int) + fun clearAllLastMessageHashes() fun getReceivedMessageHashValues(publicKey: String, namespace: Int): Set<String>? fun setReceivedMessageHashValues(publicKey: String, newValue: Set<String>, namespace: Int) + fun clearReceivedMessageHashValues() fun getAuthToken(server: String): String? fun setAuthToken(server: String, newValue: String?) fun setUserCount(room: String, server: String, newValue: Int) @@ -36,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/database/LokiOpenGroupDatabaseProtocol.kt b/libsignal/src/main/java/org/session/libsignal/database/LokiOpenGroupDatabaseProtocol.kt index 28bcecdbd9..6ccb1ab61a 100644 --- a/libsignal/src/main/java/org/session/libsignal/database/LokiOpenGroupDatabaseProtocol.kt +++ b/libsignal/src/main/java/org/session/libsignal/database/LokiOpenGroupDatabaseProtocol.kt @@ -4,4 +4,5 @@ interface LokiOpenGroupDatabaseProtocol { fun updateTitle(groupID: String, newValue: String) fun updateProfilePicture(groupID: String, newValue: ByteArray) + fun removeProfilePicture(groupID: String) } diff --git a/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceDataMessage.java b/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceDataMessage.java index de9d8d9a31..8f908284eb 100644 --- a/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceDataMessage.java +++ b/libsignal/src/main/java/org/session/libsignal/messages/SignalServiceDataMessage.java @@ -319,8 +319,8 @@ public class SignalServiceDataMessage { return this; } - public SignalServiceDataMessage build() { - if (timestamp == 0) timestamp = System.currentTimeMillis(); + public SignalServiceDataMessage build(long fallbackTimestamp) { + if (timestamp == 0) timestamp = fallbackTimestamp; // closedGroupUpdate is always null because we don't use SignalServiceDataMessage to send them (we use ClosedGroupUpdateMessageSendJob) return new SignalServiceDataMessage(timestamp, group, attachments, body, expiresInSeconds, expirationUpdate, profileKey, quote, sharedContacts, previews, null, syncTarget); 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 ead1b6255e..b53af78ea3 100644 --- a/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java +++ b/libsignal/src/main/java/org/session/libsignal/protos/SignalServiceProtos.java @@ -2468,6 +2468,50 @@ public final class SignalServiceProtos { * <code>optional .signalservice.MessageRequestResponse messageRequestResponse = 10;</code> */ org.session.libsignal.protos.SignalServiceProtos.MessageRequestResponseOrBuilder getMessageRequestResponseOrBuilder(); + + // optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + /** + * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> + */ + boolean hasSharedConfigMessage(); + /** + * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> + */ + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage getSharedConfigMessage(); + /** + * <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} @@ -2624,6 +2668,40 @@ public final class SignalServiceProtos { bitField0_ |= 0x00000080; break; } + case 90: { + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder subBuilder = null; + if (((bitField0_ & 0x00000100) == 0x00000100)) { + subBuilder = sharedConfigMessage_.toBuilder(); + } + sharedConfigMessage_ = input.readMessage(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.PARSER, extensionRegistry); + if (subBuilder != null) { + subBuilder.mergeFrom(sharedConfigMessage_); + sharedConfigMessage_ = subBuilder.buildPartial(); + } + 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) { @@ -2663,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; @@ -2840,6 +3009,76 @@ public final class SignalServiceProtos { return messageRequestResponse_; } + // optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + public static final int SHAREDCONFIGMESSAGE_FIELD_NUMBER = 11; + private org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage sharedConfigMessage_; + /** + * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> + */ + public boolean hasSharedConfigMessage() { + return ((bitField0_ & 0x00000100) == 0x00000100); + } + /** + * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage getSharedConfigMessage() { + return sharedConfigMessage_; + } + /** + * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder getSharedConfigMessageOrBuilder() { + 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(); @@ -2849,6 +3088,10 @@ public final class SignalServiceProtos { dataExtractionNotification_ = org.session.libsignal.protos.SignalServiceProtos.DataExtractionNotification.getDefaultInstance(); 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() { @@ -2903,6 +3146,12 @@ public final class SignalServiceProtos { return false; } } + if (hasSharedConfigMessage()) { + if (!getSharedConfigMessage().isInitialized()) { + memoizedIsInitialized = 0; + return false; + } + } memoizedIsInitialized = 1; return true; } @@ -2934,6 +3183,18 @@ public final class SignalServiceProtos { if (((bitField0_ & 0x00000080) == 0x00000080)) { output.writeMessage(10, messageRequestResponse_); } + 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); } @@ -2975,6 +3236,22 @@ public final class SignalServiceProtos { size += com.google.protobuf.CodedOutputStream .computeMessageSize(10, messageRequestResponse_); } + if (((bitField0_ & 0x00000100) == 0x00000100)) { + 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; @@ -3091,6 +3368,7 @@ public final class SignalServiceProtos { getDataExtractionNotificationFieldBuilder(); getUnsendRequestFieldBuilder(); getMessageRequestResponseFieldBuilder(); + getSharedConfigMessageFieldBuilder(); } } private static Builder create() { @@ -3147,6 +3425,18 @@ public final class SignalServiceProtos { messageRequestResponseBuilder_.clear(); } bitField0_ = (bitField0_ & ~0x00000080); + if (sharedConfigMessageBuilder_ == null) { + sharedConfigMessage_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); + } else { + 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; } @@ -3239,6 +3529,26 @@ public final class SignalServiceProtos { } else { result.messageRequestResponse_ = messageRequestResponseBuilder_.build(); } + if (((from_bitField0_ & 0x00000100) == 0x00000100)) { + to_bitField0_ |= 0x00000100; + } + if (sharedConfigMessageBuilder_ == null) { + result.sharedConfigMessage_ = sharedConfigMessage_; + } 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; @@ -3279,6 +3589,18 @@ public final class SignalServiceProtos { if (other.hasMessageRequestResponse()) { mergeMessageRequestResponse(other.getMessageRequestResponse()); } + 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; } @@ -3332,6 +3654,12 @@ public final class SignalServiceProtos { return false; } } + if (hasSharedConfigMessage()) { + if (!getSharedConfigMessage().isInitialized()) { + + return false; + } + } return true; } @@ -4290,6 +4618,225 @@ public final class SignalServiceProtos { return messageRequestResponseBuilder_; } + // optional .signalservice.SharedConfigMessage sharedConfigMessage = 11; + private org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage sharedConfigMessage_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); + private com.google.protobuf.SingleFieldBuilder< + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder> sharedConfigMessageBuilder_; + /** + * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> + */ + public boolean hasSharedConfigMessage() { + return ((bitField0_ & 0x00000100) == 0x00000100); + } + /** + * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage getSharedConfigMessage() { + if (sharedConfigMessageBuilder_ == null) { + return sharedConfigMessage_; + } else { + return sharedConfigMessageBuilder_.getMessage(); + } + } + /** + * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> + */ + public Builder setSharedConfigMessage(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage value) { + if (sharedConfigMessageBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + sharedConfigMessage_ = value; + onChanged(); + } else { + sharedConfigMessageBuilder_.setMessage(value); + } + bitField0_ |= 0x00000100; + return this; + } + /** + * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> + */ + public Builder setSharedConfigMessage( + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder builderForValue) { + if (sharedConfigMessageBuilder_ == null) { + sharedConfigMessage_ = builderForValue.build(); + onChanged(); + } else { + sharedConfigMessageBuilder_.setMessage(builderForValue.build()); + } + bitField0_ |= 0x00000100; + return this; + } + /** + * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> + */ + public Builder mergeSharedConfigMessage(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage value) { + if (sharedConfigMessageBuilder_ == null) { + if (((bitField0_ & 0x00000100) == 0x00000100) && + sharedConfigMessage_ != org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance()) { + sharedConfigMessage_ = + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.newBuilder(sharedConfigMessage_).mergeFrom(value).buildPartial(); + } else { + sharedConfigMessage_ = value; + } + onChanged(); + } else { + sharedConfigMessageBuilder_.mergeFrom(value); + } + bitField0_ |= 0x00000100; + return this; + } + /** + * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> + */ + public Builder clearSharedConfigMessage() { + if (sharedConfigMessageBuilder_ == null) { + sharedConfigMessage_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); + onChanged(); + } else { + sharedConfigMessageBuilder_.clear(); + } + bitField0_ = (bitField0_ & ~0x00000100); + return this; + } + /** + * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder getSharedConfigMessageBuilder() { + bitField0_ |= 0x00000100; + onChanged(); + return getSharedConfigMessageFieldBuilder().getBuilder(); + } + /** + * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder getSharedConfigMessageOrBuilder() { + if (sharedConfigMessageBuilder_ != null) { + return sharedConfigMessageBuilder_.getMessageOrBuilder(); + } else { + return sharedConfigMessage_; + } + } + /** + * <code>optional .signalservice.SharedConfigMessage sharedConfigMessage = 11;</code> + */ + private com.google.protobuf.SingleFieldBuilder< + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder> + getSharedConfigMessageFieldBuilder() { + if (sharedConfigMessageBuilder_ == null) { + sharedConfigMessageBuilder_ = new com.google.protobuf.SingleFieldBuilder< + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder>( + sharedConfigMessage_, + getParentForChildren(), + isClean()); + sharedConfigMessage_ = null; + } + 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) } @@ -5686,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} @@ -5862,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) { @@ -14132,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(); @@ -14147,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() { @@ -14244,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); } @@ -14309,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; @@ -14493,6 +15079,8 @@ public final class SignalServiceProtos { bitField0_ = (bitField0_ & ~0x00001000); syncTarget_ = ""; bitField0_ = (bitField0_ & ~0x00002000); + blocksCommunityMessageRequests_ = false; + bitField0_ = (bitField0_ & ~0x00004000); return this; } @@ -14611,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; @@ -14719,6 +15311,9 @@ public final class SignalServiceProtos { syncTarget_ = other.syncTarget_; onChanged(); } + if (other.hasBlocksCommunityMessageRequests()) { + setBlocksCommunityMessageRequests(other.getBlocksCommunityMessageRequests()); + } this.mergeUnknownFields(other.getUnknownFields()); return this; } @@ -16253,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) } @@ -21504,6 +22132,30 @@ public final class SignalServiceProtos { * </pre> */ boolean getIsApproved(); + + // optional bytes profileKey = 2; + /** + * <code>optional bytes profileKey = 2;</code> + */ + boolean hasProfileKey(); + /** + * <code>optional bytes profileKey = 2;</code> + */ + com.google.protobuf.ByteString getProfileKey(); + + // optional .signalservice.DataMessage.LokiProfile profile = 3; + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + boolean hasProfile(); + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile getProfile(); + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfileOrBuilder getProfileOrBuilder(); } /** * Protobuf type {@code signalservice.MessageRequestResponse} @@ -21561,6 +22213,24 @@ public final class SignalServiceProtos { isApproved_ = input.readBool(); break; } + case 18: { + bitField0_ |= 0x00000002; + profileKey_ = input.readBytes(); + break; + } + case 26: { + org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile.Builder subBuilder = null; + if (((bitField0_ & 0x00000004) == 0x00000004)) { + subBuilder = profile_.toBuilder(); + } + profile_ = input.readMessage(org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile.PARSER, extensionRegistry); + if (subBuilder != null) { + subBuilder.mergeFrom(profile_); + profile_ = subBuilder.buildPartial(); + } + bitField0_ |= 0x00000004; + break; + } } } } catch (com.google.protobuf.InvalidProtocolBufferException e) { @@ -21625,8 +22295,48 @@ public final class SignalServiceProtos { return isApproved_; } + // optional bytes profileKey = 2; + public static final int PROFILEKEY_FIELD_NUMBER = 2; + private com.google.protobuf.ByteString profileKey_; + /** + * <code>optional bytes profileKey = 2;</code> + */ + public boolean hasProfileKey() { + return ((bitField0_ & 0x00000002) == 0x00000002); + } + /** + * <code>optional bytes profileKey = 2;</code> + */ + public com.google.protobuf.ByteString getProfileKey() { + return profileKey_; + } + + // optional .signalservice.DataMessage.LokiProfile profile = 3; + public static final int PROFILE_FIELD_NUMBER = 3; + private org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile profile_; + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + public boolean hasProfile() { + return ((bitField0_ & 0x00000004) == 0x00000004); + } + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + public org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile getProfile() { + return profile_; + } + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + public org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfileOrBuilder getProfileOrBuilder() { + return profile_; + } + private void initFields() { isApproved_ = false; + profileKey_ = com.google.protobuf.ByteString.EMPTY; + profile_ = org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile.getDefaultInstance(); } private byte memoizedIsInitialized = -1; public final boolean isInitialized() { @@ -21647,6 +22357,12 @@ public final class SignalServiceProtos { if (((bitField0_ & 0x00000001) == 0x00000001)) { output.writeBool(1, isApproved_); } + if (((bitField0_ & 0x00000002) == 0x00000002)) { + output.writeBytes(2, profileKey_); + } + if (((bitField0_ & 0x00000004) == 0x00000004)) { + output.writeMessage(3, profile_); + } getUnknownFields().writeTo(output); } @@ -21660,6 +22376,14 @@ public final class SignalServiceProtos { size += com.google.protobuf.CodedOutputStream .computeBoolSize(1, isApproved_); } + if (((bitField0_ & 0x00000002) == 0x00000002)) { + size += com.google.protobuf.CodedOutputStream + .computeBytesSize(2, profileKey_); + } + if (((bitField0_ & 0x00000004) == 0x00000004)) { + size += com.google.protobuf.CodedOutputStream + .computeMessageSize(3, profile_); + } size += getUnknownFields().getSerializedSize(); memoizedSerializedSize = size; return size; @@ -21768,6 +22492,7 @@ public final class SignalServiceProtos { } private void maybeForceBuilderInitialization() { if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { + getProfileFieldBuilder(); } } private static Builder create() { @@ -21778,6 +22503,14 @@ public final class SignalServiceProtos { super.clear(); isApproved_ = false; bitField0_ = (bitField0_ & ~0x00000001); + profileKey_ = com.google.protobuf.ByteString.EMPTY; + bitField0_ = (bitField0_ & ~0x00000002); + if (profileBuilder_ == null) { + profile_ = org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile.getDefaultInstance(); + } else { + profileBuilder_.clear(); + } + bitField0_ = (bitField0_ & ~0x00000004); return this; } @@ -21810,6 +22543,18 @@ public final class SignalServiceProtos { to_bitField0_ |= 0x00000001; } result.isApproved_ = isApproved_; + if (((from_bitField0_ & 0x00000002) == 0x00000002)) { + to_bitField0_ |= 0x00000002; + } + result.profileKey_ = profileKey_; + if (((from_bitField0_ & 0x00000004) == 0x00000004)) { + to_bitField0_ |= 0x00000004; + } + if (profileBuilder_ == null) { + result.profile_ = profile_; + } else { + result.profile_ = profileBuilder_.build(); + } result.bitField0_ = to_bitField0_; onBuilt(); return result; @@ -21829,6 +22574,12 @@ public final class SignalServiceProtos { if (other.hasIsApproved()) { setIsApproved(other.getIsApproved()); } + if (other.hasProfileKey()) { + setProfileKey(other.getProfileKey()); + } + if (other.hasProfile()) { + mergeProfile(other.getProfile()); + } this.mergeUnknownFields(other.getUnknownFields()); return this; } @@ -21909,6 +22660,159 @@ public final class SignalServiceProtos { return this; } + // optional bytes profileKey = 2; + private com.google.protobuf.ByteString profileKey_ = com.google.protobuf.ByteString.EMPTY; + /** + * <code>optional bytes profileKey = 2;</code> + */ + public boolean hasProfileKey() { + return ((bitField0_ & 0x00000002) == 0x00000002); + } + /** + * <code>optional bytes profileKey = 2;</code> + */ + public com.google.protobuf.ByteString getProfileKey() { + return profileKey_; + } + /** + * <code>optional bytes profileKey = 2;</code> + */ + public Builder setProfileKey(com.google.protobuf.ByteString value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000002; + profileKey_ = value; + onChanged(); + return this; + } + /** + * <code>optional bytes profileKey = 2;</code> + */ + public Builder clearProfileKey() { + bitField0_ = (bitField0_ & ~0x00000002); + profileKey_ = getDefaultInstance().getProfileKey(); + onChanged(); + return this; + } + + // optional .signalservice.DataMessage.LokiProfile profile = 3; + private org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile profile_ = org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile.getDefaultInstance(); + private com.google.protobuf.SingleFieldBuilder< + org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile, org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile.Builder, org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfileOrBuilder> profileBuilder_; + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + public boolean hasProfile() { + return ((bitField0_ & 0x00000004) == 0x00000004); + } + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + public org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile getProfile() { + if (profileBuilder_ == null) { + return profile_; + } else { + return profileBuilder_.getMessage(); + } + } + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + public Builder setProfile(org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile value) { + if (profileBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + profile_ = value; + onChanged(); + } else { + profileBuilder_.setMessage(value); + } + bitField0_ |= 0x00000004; + return this; + } + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + public Builder setProfile( + org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile.Builder builderForValue) { + if (profileBuilder_ == null) { + profile_ = builderForValue.build(); + onChanged(); + } else { + profileBuilder_.setMessage(builderForValue.build()); + } + bitField0_ |= 0x00000004; + return this; + } + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + public Builder mergeProfile(org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile value) { + if (profileBuilder_ == null) { + if (((bitField0_ & 0x00000004) == 0x00000004) && + profile_ != org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile.getDefaultInstance()) { + profile_ = + org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile.newBuilder(profile_).mergeFrom(value).buildPartial(); + } else { + profile_ = value; + } + onChanged(); + } else { + profileBuilder_.mergeFrom(value); + } + bitField0_ |= 0x00000004; + return this; + } + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + public Builder clearProfile() { + if (profileBuilder_ == null) { + profile_ = org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile.getDefaultInstance(); + onChanged(); + } else { + profileBuilder_.clear(); + } + bitField0_ = (bitField0_ & ~0x00000004); + return this; + } + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + public org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile.Builder getProfileBuilder() { + bitField0_ |= 0x00000004; + onChanged(); + return getProfileFieldBuilder().getBuilder(); + } + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + public org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfileOrBuilder getProfileOrBuilder() { + if (profileBuilder_ != null) { + return profileBuilder_.getMessageOrBuilder(); + } else { + return profile_; + } + } + /** + * <code>optional .signalservice.DataMessage.LokiProfile profile = 3;</code> + */ + private com.google.protobuf.SingleFieldBuilder< + org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile, org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile.Builder, org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfileOrBuilder> + getProfileFieldBuilder() { + if (profileBuilder_ == null) { + profileBuilder_ = new com.google.protobuf.SingleFieldBuilder< + org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile, org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfile.Builder, org.session.libsignal.protos.SignalServiceProtos.DataMessage.LokiProfileOrBuilder>( + profile_, + getParentForChildren(), + isClean()); + profile_ = null; + } + return profileBuilder_; + } + // @@protoc_insertion_point(builder_scope:signalservice.MessageRequestResponse) } @@ -21920,6 +22824,823 @@ public final class SignalServiceProtos { // @@protoc_insertion_point(class_scope:signalservice.MessageRequestResponse) } + public interface SharedConfigMessageOrBuilder + extends com.google.protobuf.MessageOrBuilder { + + // required .signalservice.SharedConfigMessage.Kind kind = 1; + /** + * <code>required .signalservice.SharedConfigMessage.Kind kind = 1;</code> + * + * <pre> + * @required + * </pre> + */ + boolean hasKind(); + /** + * <code>required .signalservice.SharedConfigMessage.Kind kind = 1;</code> + * + * <pre> + * @required + * </pre> + */ + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind getKind(); + + // required int64 seqno = 2; + /** + * <code>required int64 seqno = 2;</code> + * + * <pre> + * @required + * </pre> + */ + boolean hasSeqno(); + /** + * <code>required int64 seqno = 2;</code> + * + * <pre> + * @required + * </pre> + */ + long getSeqno(); + + // required bytes data = 3; + /** + * <code>required bytes data = 3;</code> + * + * <pre> + * @required + * </pre> + */ + boolean hasData(); + /** + * <code>required bytes data = 3;</code> + * + * <pre> + * @required + * </pre> + */ + com.google.protobuf.ByteString getData(); + } + /** + * Protobuf type {@code signalservice.SharedConfigMessage} + */ + public static final class SharedConfigMessage extends + com.google.protobuf.GeneratedMessage + implements SharedConfigMessageOrBuilder { + // Use SharedConfigMessage.newBuilder() to construct. + private SharedConfigMessage(com.google.protobuf.GeneratedMessage.Builder<?> builder) { + super(builder); + this.unknownFields = builder.getUnknownFields(); + } + private SharedConfigMessage(boolean noInit) { this.unknownFields = com.google.protobuf.UnknownFieldSet.getDefaultInstance(); } + + private static final SharedConfigMessage defaultInstance; + public static SharedConfigMessage getDefaultInstance() { + return defaultInstance; + } + + public SharedConfigMessage getDefaultInstanceForType() { + return defaultInstance; + } + + private final com.google.protobuf.UnknownFieldSet unknownFields; + @java.lang.Override + public final com.google.protobuf.UnknownFieldSet + getUnknownFields() { + return this.unknownFields; + } + private SharedConfigMessage( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + initFields(); + int mutable_bitField0_ = 0; + com.google.protobuf.UnknownFieldSet.Builder unknownFields = + com.google.protobuf.UnknownFieldSet.newBuilder(); + try { + boolean done = false; + while (!done) { + int tag = input.readTag(); + switch (tag) { + case 0: + done = true; + break; + default: { + if (!parseUnknownField(input, unknownFields, + extensionRegistry, tag)) { + done = true; + } + break; + } + case 8: { + int rawValue = input.readEnum(); + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind value = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.valueOf(rawValue); + if (value == null) { + unknownFields.mergeVarintField(1, rawValue); + } else { + bitField0_ |= 0x00000001; + kind_ = value; + } + break; + } + case 16: { + bitField0_ |= 0x00000002; + seqno_ = input.readInt64(); + break; + } + case 26: { + bitField0_ |= 0x00000004; + data_ = input.readBytes(); + break; + } + } + } + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw e.setUnfinishedMessage(this); + } catch (java.io.IOException e) { + throw new com.google.protobuf.InvalidProtocolBufferException( + e.getMessage()).setUnfinishedMessage(this); + } finally { + this.unknownFields = unknownFields.build(); + makeExtensionsImmutable(); + } + } + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_descriptor; + } + + protected com.google.protobuf.GeneratedMessage.FieldAccessorTable + internalGetFieldAccessorTable() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_fieldAccessorTable + .ensureFieldAccessorsInitialized( + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.class, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder.class); + } + + public static com.google.protobuf.Parser<SharedConfigMessage> PARSER = + new com.google.protobuf.AbstractParser<SharedConfigMessage>() { + public SharedConfigMessage parsePartialFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return new SharedConfigMessage(input, extensionRegistry); + } + }; + + @java.lang.Override + public com.google.protobuf.Parser<SharedConfigMessage> getParserForType() { + return PARSER; + } + + /** + * Protobuf enum {@code signalservice.SharedConfigMessage.Kind} + */ + public enum Kind + implements com.google.protobuf.ProtocolMessageEnum { + /** + * <code>USER_PROFILE = 1;</code> + */ + USER_PROFILE(0, 1), + /** + * <code>CONTACTS = 2;</code> + */ + CONTACTS(1, 2), + /** + * <code>CONVO_INFO_VOLATILE = 3;</code> + */ + CONVO_INFO_VOLATILE(2, 3), + /** + * <code>GROUPS = 4;</code> + */ + GROUPS(3, 4), + /** + * <code>CLOSED_GROUP_INFO = 5;</code> + */ + CLOSED_GROUP_INFO(4, 5), + /** + * <code>CLOSED_GROUP_MEMBERS = 6;</code> + */ + CLOSED_GROUP_MEMBERS(5, 6), + /** + * <code>ENCRYPTION_KEYS = 7;</code> + */ + ENCRYPTION_KEYS(6, 7), + ; + + /** + * <code>USER_PROFILE = 1;</code> + */ + public static final int USER_PROFILE_VALUE = 1; + /** + * <code>CONTACTS = 2;</code> + */ + public static final int CONTACTS_VALUE = 2; + /** + * <code>CONVO_INFO_VOLATILE = 3;</code> + */ + public static final int CONVO_INFO_VOLATILE_VALUE = 3; + /** + * <code>GROUPS = 4;</code> + */ + public static final int GROUPS_VALUE = 4; + /** + * <code>CLOSED_GROUP_INFO = 5;</code> + */ + public static final int CLOSED_GROUP_INFO_VALUE = 5; + /** + * <code>CLOSED_GROUP_MEMBERS = 6;</code> + */ + public static final int CLOSED_GROUP_MEMBERS_VALUE = 6; + /** + * <code>ENCRYPTION_KEYS = 7;</code> + */ + public static final int ENCRYPTION_KEYS_VALUE = 7; + + + public final int getNumber() { return value; } + + public static Kind valueOf(int value) { + switch (value) { + case 1: return USER_PROFILE; + case 2: return CONTACTS; + case 3: return CONVO_INFO_VOLATILE; + case 4: return GROUPS; + case 5: return CLOSED_GROUP_INFO; + case 6: return CLOSED_GROUP_MEMBERS; + case 7: return ENCRYPTION_KEYS; + default: return null; + } + } + + public static com.google.protobuf.Internal.EnumLiteMap<Kind> + internalGetValueMap() { + return internalValueMap; + } + private static com.google.protobuf.Internal.EnumLiteMap<Kind> + internalValueMap = + new com.google.protobuf.Internal.EnumLiteMap<Kind>() { + public Kind findValueByNumber(int number) { + return Kind.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.SharedConfigMessage.getDescriptor().getEnumTypes().get(0); + } + + private static final Kind[] VALUES = values(); + + public static Kind 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 Kind(int index, int value) { + this.index = index; + this.value = value; + } + + // @@protoc_insertion_point(enum_scope:signalservice.SharedConfigMessage.Kind) + } + + private int bitField0_; + // required .signalservice.SharedConfigMessage.Kind kind = 1; + public static final int KIND_FIELD_NUMBER = 1; + private org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind kind_; + /** + * <code>required .signalservice.SharedConfigMessage.Kind kind = 1;</code> + * + * <pre> + * @required + * </pre> + */ + public boolean hasKind() { + return ((bitField0_ & 0x00000001) == 0x00000001); + } + /** + * <code>required .signalservice.SharedConfigMessage.Kind kind = 1;</code> + * + * <pre> + * @required + * </pre> + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind getKind() { + return kind_; + } + + // required int64 seqno = 2; + public static final int SEQNO_FIELD_NUMBER = 2; + private long seqno_; + /** + * <code>required int64 seqno = 2;</code> + * + * <pre> + * @required + * </pre> + */ + public boolean hasSeqno() { + return ((bitField0_ & 0x00000002) == 0x00000002); + } + /** + * <code>required int64 seqno = 2;</code> + * + * <pre> + * @required + * </pre> + */ + public long getSeqno() { + return seqno_; + } + + // required bytes data = 3; + public static final int DATA_FIELD_NUMBER = 3; + private com.google.protobuf.ByteString data_; + /** + * <code>required bytes data = 3;</code> + * + * <pre> + * @required + * </pre> + */ + public boolean hasData() { + return ((bitField0_ & 0x00000004) == 0x00000004); + } + /** + * <code>required bytes data = 3;</code> + * + * <pre> + * @required + * </pre> + */ + public com.google.protobuf.ByteString getData() { + return data_; + } + + private void initFields() { + kind_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.USER_PROFILE; + seqno_ = 0L; + data_ = com.google.protobuf.ByteString.EMPTY; + } + private byte memoizedIsInitialized = -1; + public final boolean isInitialized() { + byte isInitialized = memoizedIsInitialized; + if (isInitialized != -1) return isInitialized == 1; + + if (!hasKind()) { + memoizedIsInitialized = 0; + return false; + } + if (!hasSeqno()) { + memoizedIsInitialized = 0; + return false; + } + if (!hasData()) { + memoizedIsInitialized = 0; + return false; + } + memoizedIsInitialized = 1; + return true; + } + + public void writeTo(com.google.protobuf.CodedOutputStream output) + throws java.io.IOException { + getSerializedSize(); + if (((bitField0_ & 0x00000001) == 0x00000001)) { + output.writeEnum(1, kind_.getNumber()); + } + if (((bitField0_ & 0x00000002) == 0x00000002)) { + output.writeInt64(2, seqno_); + } + if (((bitField0_ & 0x00000004) == 0x00000004)) { + output.writeBytes(3, data_); + } + getUnknownFields().writeTo(output); + } + + private int memoizedSerializedSize = -1; + public int getSerializedSize() { + int size = memoizedSerializedSize; + if (size != -1) return size; + + size = 0; + if (((bitField0_ & 0x00000001) == 0x00000001)) { + size += com.google.protobuf.CodedOutputStream + .computeEnumSize(1, kind_.getNumber()); + } + if (((bitField0_ & 0x00000002) == 0x00000002)) { + size += com.google.protobuf.CodedOutputStream + .computeInt64Size(2, seqno_); + } + if (((bitField0_ & 0x00000004) == 0x00000004)) { + size += com.google.protobuf.CodedOutputStream + .computeBytesSize(3, data_); + } + size += getUnknownFields().getSerializedSize(); + memoizedSerializedSize = size; + return size; + } + + private static final long serialVersionUID = 0L; + @java.lang.Override + protected java.lang.Object writeReplace() + throws java.io.ObjectStreamException { + return super.writeReplace(); + } + + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + com.google.protobuf.ByteString data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + com.google.protobuf.ByteString data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom(byte[] data) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + byte[] data, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws com.google.protobuf.InvalidProtocolBufferException { + return PARSER.parseFrom(data, extensionRegistry); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom(java.io.InputStream input) + throws java.io.IOException { + return PARSER.parseFrom(input); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return PARSER.parseFrom(input, extensionRegistry); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseDelimitedFrom(java.io.InputStream input) + throws java.io.IOException { + return PARSER.parseDelimitedFrom(input); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseDelimitedFrom( + java.io.InputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return PARSER.parseDelimitedFrom(input, extensionRegistry); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + com.google.protobuf.CodedInputStream input) + throws java.io.IOException { + return PARSER.parseFrom(input); + } + public static org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parseFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + return PARSER.parseFrom(input, extensionRegistry); + } + + public static Builder newBuilder() { return Builder.create(); } + public Builder newBuilderForType() { return newBuilder(); } + public static Builder newBuilder(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage prototype) { + return newBuilder().mergeFrom(prototype); + } + public Builder toBuilder() { return newBuilder(this); } + + @java.lang.Override + protected Builder newBuilderForType( + com.google.protobuf.GeneratedMessage.BuilderParent parent) { + Builder builder = new Builder(parent); + return builder; + } + /** + * Protobuf type {@code signalservice.SharedConfigMessage} + */ + public static final class Builder extends + com.google.protobuf.GeneratedMessage.Builder<Builder> + implements org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessageOrBuilder { + public static final com.google.protobuf.Descriptors.Descriptor + getDescriptor() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_descriptor; + } + + protected com.google.protobuf.GeneratedMessage.FieldAccessorTable + internalGetFieldAccessorTable() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_fieldAccessorTable + .ensureFieldAccessorsInitialized( + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.class, org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Builder.class); + } + + // Construct using org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.newBuilder() + private Builder() { + maybeForceBuilderInitialization(); + } + + private Builder( + com.google.protobuf.GeneratedMessage.BuilderParent parent) { + super(parent); + maybeForceBuilderInitialization(); + } + private void maybeForceBuilderInitialization() { + if (com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders) { + } + } + private static Builder create() { + return new Builder(); + } + + public Builder clear() { + super.clear(); + kind_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.USER_PROFILE; + bitField0_ = (bitField0_ & ~0x00000001); + seqno_ = 0L; + bitField0_ = (bitField0_ & ~0x00000002); + data_ = com.google.protobuf.ByteString.EMPTY; + bitField0_ = (bitField0_ & ~0x00000004); + return this; + } + + public Builder clone() { + return create().mergeFrom(buildPartial()); + } + + public com.google.protobuf.Descriptors.Descriptor + getDescriptorForType() { + return org.session.libsignal.protos.SignalServiceProtos.internal_static_signalservice_SharedConfigMessage_descriptor; + } + + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage getDefaultInstanceForType() { + return org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance(); + } + + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage build() { + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage result = buildPartial(); + if (!result.isInitialized()) { + throw newUninitializedMessageException(result); + } + return result; + } + + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage buildPartial() { + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage result = new org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage(this); + int from_bitField0_ = bitField0_; + int to_bitField0_ = 0; + if (((from_bitField0_ & 0x00000001) == 0x00000001)) { + to_bitField0_ |= 0x00000001; + } + result.kind_ = kind_; + if (((from_bitField0_ & 0x00000002) == 0x00000002)) { + to_bitField0_ |= 0x00000002; + } + result.seqno_ = seqno_; + if (((from_bitField0_ & 0x00000004) == 0x00000004)) { + to_bitField0_ |= 0x00000004; + } + result.data_ = data_; + result.bitField0_ = to_bitField0_; + onBuilt(); + return result; + } + + public Builder mergeFrom(com.google.protobuf.Message other) { + if (other instanceof org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage) { + return mergeFrom((org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage)other); + } else { + super.mergeFrom(other); + return this; + } + } + + public Builder mergeFrom(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage other) { + if (other == org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.getDefaultInstance()) return this; + if (other.hasKind()) { + setKind(other.getKind()); + } + if (other.hasSeqno()) { + setSeqno(other.getSeqno()); + } + if (other.hasData()) { + setData(other.getData()); + } + this.mergeUnknownFields(other.getUnknownFields()); + return this; + } + + public final boolean isInitialized() { + if (!hasKind()) { + + return false; + } + if (!hasSeqno()) { + + return false; + } + if (!hasData()) { + + return false; + } + return true; + } + + public Builder mergeFrom( + com.google.protobuf.CodedInputStream input, + com.google.protobuf.ExtensionRegistryLite extensionRegistry) + throws java.io.IOException { + org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage parsedMessage = null; + try { + parsedMessage = PARSER.parsePartialFrom(input, extensionRegistry); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + parsedMessage = (org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage) e.getUnfinishedMessage(); + throw e; + } finally { + if (parsedMessage != null) { + mergeFrom(parsedMessage); + } + } + return this; + } + private int bitField0_; + + // required .signalservice.SharedConfigMessage.Kind kind = 1; + private org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind kind_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.USER_PROFILE; + /** + * <code>required .signalservice.SharedConfigMessage.Kind kind = 1;</code> + * + * <pre> + * @required + * </pre> + */ + public boolean hasKind() { + return ((bitField0_ & 0x00000001) == 0x00000001); + } + /** + * <code>required .signalservice.SharedConfigMessage.Kind kind = 1;</code> + * + * <pre> + * @required + * </pre> + */ + public org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind getKind() { + return kind_; + } + /** + * <code>required .signalservice.SharedConfigMessage.Kind kind = 1;</code> + * + * <pre> + * @required + * </pre> + */ + public Builder setKind(org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000001; + kind_ = value; + onChanged(); + return this; + } + /** + * <code>required .signalservice.SharedConfigMessage.Kind kind = 1;</code> + * + * <pre> + * @required + * </pre> + */ + public Builder clearKind() { + bitField0_ = (bitField0_ & ~0x00000001); + kind_ = org.session.libsignal.protos.SignalServiceProtos.SharedConfigMessage.Kind.USER_PROFILE; + onChanged(); + return this; + } + + // required int64 seqno = 2; + private long seqno_ ; + /** + * <code>required int64 seqno = 2;</code> + * + * <pre> + * @required + * </pre> + */ + public boolean hasSeqno() { + return ((bitField0_ & 0x00000002) == 0x00000002); + } + /** + * <code>required int64 seqno = 2;</code> + * + * <pre> + * @required + * </pre> + */ + public long getSeqno() { + return seqno_; + } + /** + * <code>required int64 seqno = 2;</code> + * + * <pre> + * @required + * </pre> + */ + public Builder setSeqno(long value) { + bitField0_ |= 0x00000002; + seqno_ = value; + onChanged(); + return this; + } + /** + * <code>required int64 seqno = 2;</code> + * + * <pre> + * @required + * </pre> + */ + public Builder clearSeqno() { + bitField0_ = (bitField0_ & ~0x00000002); + seqno_ = 0L; + onChanged(); + return this; + } + + // required bytes data = 3; + private com.google.protobuf.ByteString data_ = com.google.protobuf.ByteString.EMPTY; + /** + * <code>required bytes data = 3;</code> + * + * <pre> + * @required + * </pre> + */ + public boolean hasData() { + return ((bitField0_ & 0x00000004) == 0x00000004); + } + /** + * <code>required bytes data = 3;</code> + * + * <pre> + * @required + * </pre> + */ + public com.google.protobuf.ByteString getData() { + return data_; + } + /** + * <code>required bytes data = 3;</code> + * + * <pre> + * @required + * </pre> + */ + public Builder setData(com.google.protobuf.ByteString value) { + if (value == null) { + throw new NullPointerException(); + } + bitField0_ |= 0x00000004; + data_ = value; + onChanged(); + return this; + } + /** + * <code>required bytes data = 3;</code> + * + * <pre> + * @required + * </pre> + */ + public Builder clearData() { + bitField0_ = (bitField0_ & ~0x00000004); + data_ = getDefaultInstance().getData(); + onChanged(); + return this; + } + + // @@protoc_insertion_point(builder_scope:signalservice.SharedConfigMessage) + } + + static { + defaultInstance = new SharedConfigMessage(true); + defaultInstance.initFields(); + } + + // @@protoc_insertion_point(class_scope:signalservice.SharedConfigMessage) + } + public interface ReceiptMessageOrBuilder extends com.google.protobuf.MessageOrBuilder { @@ -25805,6 +27526,11 @@ public final class SignalServiceProtos { private static com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_signalservice_MessageRequestResponse_fieldAccessorTable; + private static com.google.protobuf.Descriptors.Descriptor + internal_static_signalservice_SharedConfigMessage_descriptor; + private static + com.google.protobuf.GeneratedMessage.FieldAccessorTable + internal_static_signalservice_SharedConfigMessage_fieldAccessorTable; private static com.google.protobuf.Descriptors.Descriptor internal_static_signalservice_ReceiptMessage_descriptor; private static @@ -25839,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\"\345\003\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" + @@ -25851,94 +27577,110 @@ public final class SignalServiceProtos { "e.DataExtractionNotification\0223\n\runsendRe", "quest\030\t \001(\0132\034.signalservice.UnsendReques" + "t\022E\n\026messageRequestResponse\030\n \001(\0132%.sign" + - "alservice.MessageRequestResponse\"0\n\007KeyP" + - "air\022\021\n\tpublicKey\030\001 \002(\014\022\022\n\nprivateKey\030\002 \002" + - "(\014\"\226\001\n\032DataExtractionNotification\022<\n\004typ" + - "e\030\001 \002(\0162..signalservice.DataExtractionNo" + - "tification.Type\022\021\n\ttimestamp\030\002 \001(\004\"\'\n\004Ty" + - "pe\022\016\n\nSCREENSHOT\020\001\022\017\n\013MEDIA_SAVED\020\002\"\361\r\n\013" + - "DataMessage\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.GroupCon" + - "text\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.DataMessa" + - "ge.Quote\0223\n\007preview\030\n \003(\0132\".signalservic" + - "e.DataMessage.Preview\0225\n\010reaction\030\013 \001(\0132" + - "#.signalservice.DataMessage.Reaction\0227\n\007" + - "profile\030e \001(\0132&.signalservice.DataMessag" + - "e.LokiProfile\022K\n\023openGroupInvitation\030f \001" + - "(\0132..signalservice.DataMessage.OpenGroup", - "Invitation\022W\n\031closedGroupControlMessage\030" + - "h \001(\01324.signalservice.DataMessage.Closed" + - "GroupControlMessage\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\013attachments\030\004 \003(\01321.signa" + - "lservice.DataMessage.Quote.QuotedAttachm" + - "ent\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" + - "(\0132 .signalservice.AttachmentPointer\022\r\n\005" + - "flags\030\004 \001(\r\"\032\n\005Flags\022\021\n\rVOICE_MESSAGE\020\001\032", - "V\n\007Preview\022\013\n\003url\030\001 \002(\t\022\r\n\005title\030\002 \001(\t\022/" + - "\n\005image\030\003 \001(\0132 .signalservice.Attachment" + - "Pointer\032:\n\013LokiProfile\022\023\n\013displayName\030\001 " + - "\001(\t\022\026\n\016profilePicture\030\002 \001(\t\0320\n\023OpenGroup" + - "Invitation\022\013\n\003url\030\001 \002(\t\022\014\n\004name\030\003 \002(\t\032\374\003" + - "\n\031ClosedGroupControlMessage\022G\n\004type\030\001 \002(" + - "\01629.signalservice.DataMessage.ClosedGrou" + - "pControlMessage.Type\022\021\n\tpublicKey\030\002 \001(\014\022" + - "\014\n\004name\030\003 \001(\t\0221\n\021encryptionKeyPair\030\004 \001(\013" + - "2\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.si" + - "gnalservice.DataMessage.ClosedGroupContr" + - "olMessage.KeyPairWrapper\022\027\n\017expirationTi" + - "mer\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\013NA" + - "ME_CHANGE\020\004\022\021\n\rMEMBERS_ADDED\020\005\022\023\n\017MEMBER" + - "S_REMOVED\020\006\022\017\n\013MEMBER_LEFT\020\007\032\222\001\n\010Reactio" + - "n\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.Dat", - "aMessage.Reaction.Action\"\037\n\006Action\022\t\n\005RE" + - "ACT\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.signalservice.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\t" + - "PRE_OFFER\020\006\022\t\n\005OFFER\020\001\022\n\n\006ANSWER\020\002\022\026\n\022PR" + - "OVISIONAL_ANSWER\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.Conf", - "igurationMessage.ClosedGroup\022\022\n\nopenGrou" + - "ps\030\002 \003(\t\022\023\n\013displayName\030\003 \001(\t\022\026\n\016profile" + - "Picture\030\004 \001(\t\022\022\n\nprofileKey\030\005 \001(\014\022=\n\010con" + - "tacts\030\006 \003(\0132+.signalservice.Configuratio" + - "nMessage.Contact\032\233\001\n\013ClosedGroup\022\021\n\tpubl" + - "icKey\030\001 \001(\014\022\014\n\004name\030\002 \001(\t\0221\n\021encryptionK" + - "eyPair\030\003 \001(\0132\026.signalservice.KeyPair\022\017\n\007" + - "members\030\004 \003(\014\022\016\n\006admins\030\005 \003(\014\022\027\n\017expirat" + - "ionTimer\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\",\n\026MessageRequestResponse\022\022\n\nisAppr" + - "oved\030\001 \002(\010\"u\n\016ReceiptMessage\0220\n\004type\030\001 \002" + - "(\0162\".signalservice.ReceiptMessage.Type\022\021" + - "\n\ttimestamp\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" + - "\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\005flags\030\010 \001(\r\022", - "\r\n\005width\030\t \001(\r\022\016\n\006height\030\n \001(\r\022\017\n\007captio" + - "n\030\013 \001(\t\022\013\n\003url\030e \001(\t\"\032\n\005Flags\022\021\n\rVOICE_M" + - "ESSAGE\020\001\"\365\001\n\014GroupContext\022\n\n\002id\030\001 \001(\014\022.\n" + - "\004type\030\002 \001(\0162 .signalservice.GroupContext" + - ".Type\022\014\n\004name\030\003 \001(\t\022\017\n\007members\030\004 \003(\t\0220\n\006" + - "avatar\030\005 \001(\0132 .signalservice.AttachmentP" + - "ointer\022\016\n\006admins\030\006 \003(\t\"H\n\004Type\022\013\n\007UNKNOW" + - "N\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.session.libsigna" + - "l.protosB\023SignalServiceProtos" + "alservice.MessageRequestResponse\022?\n\023shar" + + "edConfigMessage\030\013 \001(\0132\".signalservice.Sh" + + "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() { @@ -25968,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", }); + 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 @@ -25986,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 @@ -26064,21 +27806,27 @@ public final class SignalServiceProtos { internal_static_signalservice_MessageRequestResponse_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_MessageRequestResponse_descriptor, - new java.lang.String[] { "IsApproved", }); - internal_static_signalservice_ReceiptMessage_descriptor = + new java.lang.String[] { "IsApproved", "ProfileKey", "Profile", }); + internal_static_signalservice_SharedConfigMessage_descriptor = getDescriptor().getMessageTypes().get(10); + internal_static_signalservice_SharedConfigMessage_fieldAccessorTable = new + com.google.protobuf.GeneratedMessage.FieldAccessorTable( + internal_static_signalservice_SharedConfigMessage_descriptor, + new java.lang.String[] { "Kind", "Seqno", "Data", }); + internal_static_signalservice_ReceiptMessage_descriptor = + getDescriptor().getMessageTypes().get(11); internal_static_signalservice_ReceiptMessage_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_ReceiptMessage_descriptor, new java.lang.String[] { "Type", "Timestamp", }); internal_static_signalservice_AttachmentPointer_descriptor = - getDescriptor().getMessageTypes().get(11); + getDescriptor().getMessageTypes().get(12); internal_static_signalservice_AttachmentPointer_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_AttachmentPointer_descriptor, new java.lang.String[] { "Id", "ContentType", "Key", "Size", "Thumbnail", "Digest", "FileName", "Flags", "Width", "Height", "Caption", "Url", }); internal_static_signalservice_GroupContext_descriptor = - getDescriptor().getMessageTypes().get(12); + getDescriptor().getMessageTypes().get(13); internal_static_signalservice_GroupContext_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_signalservice_GroupContext_descriptor, diff --git a/libsignal/src/main/java/org/session/libsignal/streams/AttachmentCipherInputStream.java b/libsignal/src/main/java/org/session/libsignal/streams/AttachmentCipherInputStream.java index 3158d35f73..fd3c8123df 100644 --- a/libsignal/src/main/java/org/session/libsignal/streams/AttachmentCipherInputStream.java +++ b/libsignal/src/main/java/org/session/libsignal/streams/AttachmentCipherInputStream.java @@ -6,6 +6,8 @@ package org.session.libsignal.streams; +import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK; + import org.session.libsignal.exceptions.InvalidMacException; import org.session.libsignal.exceptions.InvalidMessageException; import org.session.libsignal.utilities.Util; @@ -92,19 +94,15 @@ public class AttachmentCipherInputStream extends FilterInputStream { byte[] iv = new byte[BLOCK_SIZE]; readFully(iv); - this.cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); - this.cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv)); + synchronized (CIPHER_LOCK) { + this.cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + this.cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv)); + } this.done = false; this.totalRead = 0; this.totalDataSize = totalDataSize; - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } catch (InvalidKeyException e) { - throw new AssertionError(e); - } catch (NoSuchPaddingException e) { - throw new AssertionError(e); - } catch (InvalidAlgorithmParameterException e) { + } catch (NoSuchAlgorithmException | InvalidKeyException | NoSuchPaddingException | InvalidAlgorithmParameterException e) { throw new AssertionError(e); } } @@ -141,15 +139,12 @@ public class AttachmentCipherInputStream extends FilterInputStream { private int readFinal(byte[] buffer, int offset, int length) throws IOException { try { - int flourish = cipher.doFinal(buffer, offset); - - done = true; - return flourish; - } catch (IllegalBlockSizeException e) { - throw new IOException(e); - } catch (BadPaddingException e) { - throw new IOException(e); - } catch (ShortBufferException e) { + synchronized (CIPHER_LOCK) { + int flourish = cipher.doFinal(buffer, offset); + done = true; + return flourish; + } + } catch (IllegalBlockSizeException | ShortBufferException | BadPaddingException e) { throw new IOException(e); } } @@ -234,9 +229,7 @@ public class AttachmentCipherInputStream extends FilterInputStream { throw new InvalidMacException("Digest doesn't match!"); } - } catch (IOException e) { - throw new InvalidMacException(e); - } catch (ArithmeticException e) { + } catch (IOException | ArithmeticException e) { throw new InvalidMacException(e); } catch (NoSuchAlgorithmException e) { throw new AssertionError(e); diff --git a/libsignal/src/main/java/org/session/libsignal/streams/AttachmentCipherOutputStream.java b/libsignal/src/main/java/org/session/libsignal/streams/AttachmentCipherOutputStream.java index 91c3563700..2f58c84c78 100644 --- a/libsignal/src/main/java/org/session/libsignal/streams/AttachmentCipherOutputStream.java +++ b/libsignal/src/main/java/org/session/libsignal/streams/AttachmentCipherOutputStream.java @@ -6,6 +6,8 @@ package org.session.libsignal.streams; +import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK; + import org.session.libsignal.utilities.Util; import java.io.IOException; @@ -68,16 +70,17 @@ public class AttachmentCipherOutputStream extends DigestingOutputStream { @Override public void flush() throws IOException { try { - byte[] ciphertext = cipher.doFinal(); + byte[] ciphertext; + synchronized (CIPHER_LOCK) { + ciphertext = cipher.doFinal(); + } byte[] auth = mac.doFinal(ciphertext); super.write(ciphertext); super.write(auth); super.flush(); - } catch (IllegalBlockSizeException e) { - throw new AssertionError(e); - } catch (BadPaddingException e) { + } catch (IllegalBlockSizeException | BadPaddingException e) { throw new AssertionError(e); } } @@ -97,9 +100,7 @@ public class AttachmentCipherOutputStream extends DigestingOutputStream { private Cipher initializeCipher() { try { return Cipher.getInstance("AES/CBC/PKCS5Padding"); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } catch (NoSuchPaddingException e) { + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { throw new AssertionError(e); } } diff --git a/libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherInputStream.java b/libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherInputStream.java index aa15eb00c6..19996c17cb 100644 --- a/libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherInputStream.java +++ b/libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherInputStream.java @@ -1,5 +1,7 @@ package org.session.libsignal.streams; +import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK; + import org.session.libsignal.utilities.Util; import java.io.FilterInputStream; @@ -62,23 +64,23 @@ public class ProfileCipherInputStream extends FilterInputStream { byte[] ciphertext = new byte[outputLength / 2]; int read = in.read(ciphertext, 0, ciphertext.length); - if (read == -1) { - if (cipher.getOutputSize(0) > outputLength) { - throw new AssertionError("Need: " + cipher.getOutputSize(0) + " but only have: " + outputLength); - } + synchronized (CIPHER_LOCK) { + if (read == -1) { + if (cipher.getOutputSize(0) > outputLength) { + throw new AssertionError("Need: " + cipher.getOutputSize(0) + " but only have: " + outputLength); + } - finished = true; - return cipher.doFinal(output, outputOffset); - } else { - if (cipher.getOutputSize(read) > outputLength) { - throw new AssertionError("Need: " + cipher.getOutputSize(read) + " but only have: " + outputLength); - } + finished = true; + return cipher.doFinal(output, outputOffset); + } else { + if (cipher.getOutputSize(read) > outputLength) { + throw new AssertionError("Need: " + cipher.getOutputSize(read) + " but only have: " + outputLength); + } - return cipher.update(ciphertext, 0, read, output, outputOffset); + return cipher.update(ciphertext, 0, read, output, outputOffset); + } } - } catch (IllegalBlockSizeException e) { - throw new AssertionError(e); - } catch(ShortBufferException e) { + } catch (IllegalBlockSizeException | ShortBufferException e) { throw new AssertionError(e); } catch (BadPaddingException e) { throw new IOException(e); diff --git a/libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java b/libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java index 9d4e13a0c2..f47a5f72b6 100644 --- a/libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java +++ b/libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java @@ -1,5 +1,7 @@ package org.session.libsignal.streams; +import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK; + import java.io.IOException; import java.io.OutputStream; import java.security.InvalidAlgorithmParameterException; @@ -54,20 +56,24 @@ public class ProfileCipherOutputStream extends DigestingOutputStream { byte[] input = new byte[1]; input[0] = (byte)b; - byte[] output = cipher.update(input); + byte[] output; + synchronized (CIPHER_LOCK) { + output = cipher.update(input); + } super.write(output); } @Override public void flush() throws IOException { try { - byte[] output = cipher.doFinal(); + byte[] output; + synchronized (CIPHER_LOCK) { + output = cipher.doFinal(); + } super.write(output); super.flush(); - } catch (BadPaddingException e) { - throw new AssertionError(e); - } catch (IllegalBlockSizeException e) { + } catch (BadPaddingException | IllegalBlockSizeException e) { throw new AssertionError(e); } } 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/HTTP.kt b/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt index aea1fce2d9..5eac7cecd4 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt @@ -12,6 +12,7 @@ import javax.net.ssl.SSLContext import javax.net.ssl.X509TrustManager object HTTP { + var isConnectedToNetwork: (() -> Boolean) = { false } private val seedNodeConnection by lazy { OkHttpClient().newBuilder() @@ -64,8 +65,12 @@ object HTTP { private const val timeout: Long = 120 - class HTTPRequestFailedException(val statusCode: Int, val json: Map<*, *>?) - : kotlin.Exception("HTTP request failed with status code $statusCode.") + open class HTTPRequestFailedException( + val statusCode: Int, + val json: Map<*, *>?, + message: String = "HTTP request failed with status code $statusCode" + ) : kotlin.Exception(message) + class HTTPNoNetworkException : HTTPRequestFailedException(0, null, "No network connection") enum class Verb(val rawValue: String) { GET("GET"), PUT("PUT"), POST("POST"), DELETE("DELETE") @@ -120,8 +125,11 @@ object HTTP { response = connection.newCall(request.build()).execute() } catch (exception: Exception) { Log.d("Loki", "${verb.rawValue} request to $url failed due to error: ${exception.localizedMessage}.") + + if (!isConnectedToNetwork()) { throw HTTPNoNetworkException() } + // Override the actual error so that we can correctly catch failed requests in OnionRequestAPI - throw HTTPRequestFailedException(0, null) + throw HTTPRequestFailedException(0, null, "HTTP request failed due to: ${exception.message}") } return when (val statusCode = response.code()) { 200 -> { diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt b/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt index 154b91ee20..26c62ba50d 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/IdPrefix.kt @@ -1,12 +1,15 @@ package org.session.libsignal.utilities enum class IdPrefix(val value: String) { - STANDARD("05"), BLINDED("15"), UN_BLINDED("00"); + STANDARD("05"), BLINDED("15"), UN_BLINDED("00"), BLINDEDV2("25"); + + fun isBlinded() = value == BLINDED.value || value == BLINDEDV2.value companion object { fun fromValue(rawValue: String): IdPrefix? = when(rawValue.take(2)) { STANDARD.value -> STANDARD BLINDED.value -> BLINDED + BLINDEDV2.value -> BLINDEDV2 UN_BLINDED.value -> UN_BLINDED else -> null } diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Namespace.kt b/libsignal/src/main/java/org/session/libsignal/utilities/Namespace.kt index 1c635d9934..ba04e516aa 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Namespace.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Namespace.kt @@ -1,7 +1,7 @@ package org.session.libsignal.utilities object Namespace { + const val ALL = "all" const val DEFAULT = 0 const val UNAUTHENTICATED_CLOSED_GROUP = -10 - const val CONFIGURATION = 5 } \ No newline at end of file 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/Snode.kt b/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt index cfbedb7338..28f8aeb03b 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt @@ -5,12 +5,16 @@ class Snode(val address: String, val port: Int, val publicKeySet: KeySet?) { public enum class Method(val rawValue: String) { GetSwarm("get_snodes_for_pubkey"), - GetMessages("retrieve"), + Retrieve("retrieve"), SendMessage("store"), DeleteMessage("delete"), OxenDaemonRPCCall("oxend_request"), Info("info"), - DeleteAll("delete_all") + DeleteAll("delete_all"), + Batch("batch"), + Sequence("sequence"), + Expire("expire"), + GetExpiries("get_expiries") } data class KeySet(val ed25519Key: String, val x25519Key: String) 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 0700d01f66..e920d85b47 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/ThreadUtils.kt @@ -1,24 +1,60 @@ package org.session.libsignal.utilities +import android.os.Process import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.SynchronousQueue import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit object ThreadUtils { - val executorPool: ExecutorService = Executors.newCachedThreadPool() + const val TAG = "ThreadUtils" + + const val PRIORITY_IMPORTANT_BACKGROUND_THREAD = Process.THREAD_PRIORITY_DEFAULT + Process.THREAD_PRIORITY_LESS_FAVORABLE + + // Paraphrased from: https://www.baeldung.com/kotlin/create-thread-pool + // "A cached thread pool such as one created via: + // `val executorPool: ExecutorService = Executors.newCachedThreadPool()` + // will utilize resources according to the requirements of submitted tasks. It will try to reuse + // existing threads for submitted tasks but will create as many threads as it needs if new tasks + // keep pouring in (with a memory usage of at least 1MB per created thread). These threads will + // live for up to 60 seconds of idle time before terminating by default. As such, it presents a + // very sharp tool that doesn't include any backpressure mechanism - and a sudden peak in load + // can bring the system down with an OutOfMemory error. We can achieve a similar effect but with + // better control by creating a ThreadPoolExecutor manually." + + private val corePoolSize = Runtime.getRuntime().availableProcessors() // Default thread pool size is our CPU core count + private val maxPoolSize = corePoolSize * 4 // Allow a maximum pool size of up to 4 threads per core + private val keepAliveTimeSecs = 100L // How long to keep idle threads in the pool before they are terminated + private val workQueue = SynchronousQueue<Runnable>() + val executorPool: ExecutorService = ThreadPoolExecutor(corePoolSize, maxPoolSize, keepAliveTimeSecs, TimeUnit.SECONDS, workQueue) + + // 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) + executorPool.execute { + try { + target.run() + } catch (e: Exception) { + Log.e(TAG, e) + } + } } fun queue(target: () -> Unit) { - executorPool.execute(target) + executorPool.execute { + 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 diff --git a/settings.gradle b/settings.gradle index 3a42510472..7ab26e097c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,4 +3,5 @@ rootProject.name = "session-android" include ':app' include ':liblazysodium' include ':libsession' -include ':libsignal' \ No newline at end of file +include ':libsignal' +include ':libsession-util'